Compare commits

...
2 Commits
Author SHA1 Message Date
npc0-hue a0e7ae362b Add SCUM trade catalog projections 2026-08-27 16:14:59 +08:00
npc0-hue 316efbe780 Add SCUM log sessions and trajectory projections 2026-08-27 12:34:07 +08:00
42 changed files with 1804 additions and 95 deletions
+94 -9
View File
@@ -44,6 +44,19 @@ type GameClientBridgeQueryTemplateDeclaration struct {
MaxRows int
TimeoutSeconds int
PollIntervalSeconds int
Projections []GameClientBridgeQueryProjectionDeclaration
}
type GameClientBridgeQueryProjectionDeclaration struct {
Collection string
RowPath string
MatchField string
MatchValue string
UpsertKeys []string
FieldMappings map[string]string
FixedValues map[string]string
ObservedAtField string
MergeExisting bool
}
type GameClientBridgeLogProjectionStepDeclaration struct {
@@ -54,6 +67,7 @@ type GameClientBridgeLogProjectionTargetDeclaration struct {
Collection string
UpsertKeys []string
CaptureMappings map[string]string
HashMappings map[string]string
FixedValues map[string]string
ObservedAtField string
}
@@ -64,6 +78,30 @@ type GameClientBridgeLogProjectionPresenceDeclaration struct {
ActivityTarget *GameClientBridgeLogProjectionTargetDeclaration
}
type GameClientBridgeLifecycleProjectionDeclaration struct {
Key string
Capabilities []string
ProcessStates []string
Target GameClientBridgeBulkProjectionTargetDeclaration
}
type GameClientBridgeBulkProjectionTargetDeclaration struct {
Collection string
MatchField string
MatchValue string
FixedValues map[string]string
ObservedAtField string
ActivityTarget *GameClientBridgeBulkActivityTargetDeclaration
}
type GameClientBridgeBulkActivityTargetDeclaration struct {
Collection string
UpsertKeys []string
RowMappings map[string]string
FixedValues map[string]string
ObservedAtField string
}
type GameClientBridgeLogProjectionDeclaration struct {
Key string
StreamKeys []string
@@ -115,15 +153,16 @@ type GameClientBridgeCompanionDeclaration struct {
}
type GameClientBridgeManifest struct {
Commands []GameClientBridgeCommandDeclaration
Snapshots []GameClientBridgeSnapshotDeclaration
QueryTemplates []GameClientBridgeQueryTemplateDeclaration
LogProjections []GameClientBridgeLogProjectionDeclaration
DataPacks []GameClientBridgeDataPackDeclaration
Retention GameClientBridgeRetention
Pages []GameClientBridgePageContract
Features []GameClientBridgeFeatureDeclaration
Companion GameClientBridgeCompanionDeclaration
Commands []GameClientBridgeCommandDeclaration
Snapshots []GameClientBridgeSnapshotDeclaration
QueryTemplates []GameClientBridgeQueryTemplateDeclaration
LogProjections []GameClientBridgeLogProjectionDeclaration
LifecycleProjections []GameClientBridgeLifecycleProjectionDeclaration
DataPacks []GameClientBridgeDataPackDeclaration
Retention GameClientBridgeRetention
Pages []GameClientBridgePageContract
Features []GameClientBridgeFeatureDeclaration
Companion GameClientBridgeCompanionDeclaration
}
type GameClientBridgeResultStatus string
@@ -394,10 +433,17 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
value.Commands = append([]GameClientBridgeCommandDeclaration(nil), value.Commands...)
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
for index := range value.QueryTemplates {
value.QueryTemplates[index] = CopyGameClientBridgeQueryTemplateDeclaration(value.QueryTemplates[index])
}
value.LogProjections = append([]GameClientBridgeLogProjectionDeclaration(nil), value.LogProjections...)
for index := range value.LogProjections {
value.LogProjections[index] = CopyGameClientBridgeLogProjectionDeclaration(value.LogProjections[index])
}
value.LifecycleProjections = append([]GameClientBridgeLifecycleProjectionDeclaration(nil), value.LifecycleProjections...)
for index := range value.LifecycleProjections {
value.LifecycleProjections[index] = CopyGameClientBridgeLifecycleProjectionDeclaration(value.LifecycleProjections[index])
}
value.DataPacks = append([]GameClientBridgeDataPackDeclaration(nil), value.DataPacks...)
for index := range value.DataPacks {
value.DataPacks[index].LogParserRefs = CopyStringSlice(value.DataPacks[index].LogParserRefs)
@@ -419,6 +465,44 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
return value
}
func CopyGameClientBridgeQueryTemplateDeclaration(value GameClientBridgeQueryTemplateDeclaration) GameClientBridgeQueryTemplateDeclaration {
value.Projections = append([]GameClientBridgeQueryProjectionDeclaration(nil), value.Projections...)
for index := range value.Projections {
value.Projections[index] = CopyGameClientBridgeQueryProjectionDeclaration(value.Projections[index])
}
return value
}
func CopyGameClientBridgeQueryProjectionDeclaration(value GameClientBridgeQueryProjectionDeclaration) GameClientBridgeQueryProjectionDeclaration {
value.UpsertKeys = CopyStringSlice(value.UpsertKeys)
value.FieldMappings = CopyStringMap(value.FieldMappings)
value.FixedValues = CopyStringMap(value.FixedValues)
return value
}
func CopyGameClientBridgeLifecycleProjectionDeclaration(value GameClientBridgeLifecycleProjectionDeclaration) GameClientBridgeLifecycleProjectionDeclaration {
value.Capabilities = CopyStringSlice(value.Capabilities)
value.ProcessStates = CopyStringSlice(value.ProcessStates)
value.Target = CopyGameClientBridgeBulkProjectionTargetDeclaration(value.Target)
return value
}
func CopyGameClientBridgeBulkProjectionTargetDeclaration(value GameClientBridgeBulkProjectionTargetDeclaration) GameClientBridgeBulkProjectionTargetDeclaration {
value.FixedValues = CopyStringMap(value.FixedValues)
if value.ActivityTarget != nil {
activity := CopyGameClientBridgeBulkActivityTargetDeclaration(*value.ActivityTarget)
value.ActivityTarget = &activity
}
return value
}
func CopyGameClientBridgeBulkActivityTargetDeclaration(value GameClientBridgeBulkActivityTargetDeclaration) GameClientBridgeBulkActivityTargetDeclaration {
value.UpsertKeys = CopyStringSlice(value.UpsertKeys)
value.RowMappings = CopyStringMap(value.RowMappings)
value.FixedValues = CopyStringMap(value.FixedValues)
return value
}
func CopyGameClientBridgeLogProjectionDeclaration(value GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclaration {
value.StreamKeys = CopyStringSlice(value.StreamKeys)
value.Steps = append([]GameClientBridgeLogProjectionStepDeclaration(nil), value.Steps...)
@@ -438,6 +522,7 @@ func CopyGameClientBridgeLogProjectionDeclaration(value GameClientBridgeLogProje
func CopyGameClientBridgeLogProjectionTargetDeclaration(value GameClientBridgeLogProjectionTargetDeclaration) GameClientBridgeLogProjectionTargetDeclaration {
value.UpsertKeys = CopyStringSlice(value.UpsertKeys)
value.CaptureMappings = CopyStringMap(value.CaptureMappings)
value.HashMappings = CopyStringMap(value.HashMappings)
value.FixedValues = CopyStringMap(value.FixedValues)
return value
}
+10 -5
View File
@@ -4,24 +4,29 @@ import "testing"
func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) {
manifest := GameClientBridgeManifest{
QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", PollIntervalSeconds: 3, SQLRef: "sql/player-lookup.sql"}},
QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", PollIntervalSeconds: 3, SQLRef: "sql/player-lookup.sql", Projections: []GameClientBridgeQueryProjectionDeclaration{{Collection: "users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt", MergeExisting: true}}}},
LogProjections: []GameClientBridgeLogProjectionDeclaration{{
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"}}},
}},
DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", LogParserRefs: []string{"logs.json"}, ConfigMapRefs: []string{"config.json"}, DataRefs: []string{"data.json"}}},
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
LifecycleProjections: []GameClientBridgeLifecycleProjectionDeclaration{{Key: "server.stop", Capabilities: []string{"process.stop"}, ProcessStates: []string{"stopped"}, Target: GameClientBridgeBulkProjectionTargetDeclaration{Collection: "users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false"}, ActivityTarget: &GameClientBridgeBulkActivityTargetDeclaration{Collection: "activity", UpsertKeys: []string{"steamId"}, RowMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"eventType": "logout"}}}}},
DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", LogParserRefs: []string{"logs.json"}, ConfigMapRefs: []string{"config.json"}, DataRefs: []string{"data.json"}}},
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
}
manifestCopy := CopyGameClientBridgeManifest(manifest)
manifestCopy.QueryTemplates[0].Key = "mutated"
manifestCopy.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "mutated"
manifestCopy.LogProjections[0].StreamKeys[0] = "mutated"
manifestCopy.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated"
manifestCopy.LogProjections[0].Target.HashMappings["networkCorrelation"] = "mutated"
manifestCopy.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] = "mutated"
manifestCopy.LifecycleProjections[0].Capabilities[0] = "mutated"
manifestCopy.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] = "mutated"
manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated"
manifestCopy.DataPacks[0].DataRefs[0] = "mutated"
manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated"
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || manifest.LogProjections[0].StreamKeys[0] != "process.stdout" || manifest.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" || manifest.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] != "steamId" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.DataPacks[0].DataRefs[0] != "data.json" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || manifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] != "steamId" || !manifest.QueryTemplates[0].Projections[0].MergeExisting || 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)
}
+129 -28
View File
@@ -279,18 +279,31 @@ type GameClientBridgeSnapshotDeclarationBody struct {
}
type GameClientBridgeQueryTemplateDeclarationBody struct {
Key string `json:"key"`
Title string `json:"title"`
Permission string `json:"permission"`
Engine string `json:"engine"`
TransportKey string `json:"transportKey"`
TargetKey string `json:"targetKey"`
ParameterSchemaRef string `json:"parameterSchemaRef"`
ResultSchemaRef string `json:"resultSchemaRef"`
SQLRef string `json:"sqlRef,omitempty"`
MaxRows int `json:"maxRows"`
TimeoutSeconds int `json:"timeoutSeconds"`
PollIntervalSeconds int `json:"pollIntervalSeconds"`
Key string `json:"key"`
Title string `json:"title"`
Permission string `json:"permission"`
Engine string `json:"engine"`
TransportKey string `json:"transportKey"`
TargetKey string `json:"targetKey"`
ParameterSchemaRef string `json:"parameterSchemaRef"`
ResultSchemaRef string `json:"resultSchemaRef"`
SQLRef string `json:"sqlRef,omitempty"`
MaxRows int `json:"maxRows"`
TimeoutSeconds int `json:"timeoutSeconds"`
PollIntervalSeconds int `json:"pollIntervalSeconds"`
Projections []GameClientBridgeQueryProjectionDeclarationBody `json:"projections,omitempty"`
}
type GameClientBridgeQueryProjectionDeclarationBody struct {
Collection string `json:"collection"`
RowPath string `json:"rowPath"`
MatchField string `json:"matchField,omitempty"`
MatchValue string `json:"matchValue,omitempty"`
UpsertKeys []string `json:"upsertKeys"`
FieldMappings map[string]string `json:"fieldMappings,omitempty"`
FixedValues map[string]string `json:"fixedValues,omitempty"`
ObservedAtField string `json:"observedAtField,omitempty"`
MergeExisting bool `json:"mergeExisting,omitempty"`
}
type GameClientBridgeLogProjectionStepDeclarationBody struct {
@@ -301,6 +314,7 @@ type GameClientBridgeLogProjectionTargetDeclarationBody struct {
Collection string `json:"collection"`
UpsertKeys []string `json:"upsertKeys"`
CaptureMappings map[string]string `json:"captureMappings"`
HashMappings map[string]string `json:"hashMappings,omitempty"`
FixedValues map[string]string `json:"fixedValues,omitempty"`
ObservedAtField string `json:"observedAtField,omitempty"`
}
@@ -311,6 +325,30 @@ type GameClientBridgeLogProjectionPresenceDeclarationBody struct {
ActivityTarget *GameClientBridgeLogProjectionTargetDeclarationBody `json:"activityTarget,omitempty"`
}
type GameClientBridgeLifecycleProjectionDeclarationBody struct {
Key string `json:"key"`
Capabilities []string `json:"capabilities"`
ProcessStates []string `json:"processStates,omitempty"`
Target GameClientBridgeBulkProjectionTargetBody `json:"target"`
}
type GameClientBridgeBulkProjectionTargetBody struct {
Collection string `json:"collection"`
MatchField string `json:"matchField"`
MatchValue string `json:"matchValue"`
FixedValues map[string]string `json:"fixedValues"`
ObservedAtField string `json:"observedAtField,omitempty"`
ActivityTarget *GameClientBridgeBulkActivityTargetBody `json:"activityTarget,omitempty"`
}
type GameClientBridgeBulkActivityTargetBody struct {
Collection string `json:"collection"`
UpsertKeys []string `json:"upsertKeys"`
RowMappings map[string]string `json:"rowMappings"`
FixedValues map[string]string `json:"fixedValues,omitempty"`
ObservedAtField string `json:"observedAtField,omitempty"`
}
type GameClientBridgeLogProjectionDeclarationBody struct {
Key string `json:"key"`
StreamKeys []string `json:"streamKeys"`
@@ -362,16 +400,17 @@ type GameClientBridgeCompanionDeclarationBody struct {
}
type GameClientBridgeManifestBody struct {
Commands []GameClientBridgeCommandDeclarationBody `json:"commands"`
Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"`
QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"`
LogProjections []GameClientBridgeLogProjectionDeclarationBody `json:"logProjections,omitempty"`
DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"`
CommandRetentionSeconds int `json:"commandRetentionSeconds"`
MaxCommands int `json:"maxCommands"`
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"`
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"`
Commands []GameClientBridgeCommandDeclarationBody `json:"commands"`
Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"`
QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"`
LogProjections []GameClientBridgeLogProjectionDeclarationBody `json:"logProjections,omitempty"`
LifecycleProjections []GameClientBridgeLifecycleProjectionDeclarationBody `json:"lifecycleProjections,omitempty"`
DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"`
CommandRetentionSeconds int `json:"commandRetentionSeconds"`
MaxCommands int `json:"maxCommands"`
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"`
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"`
}
type GamePluginManifestBody struct {
ID string `json:"id"`
@@ -1242,12 +1281,16 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
}
queryTemplates := make([]domain.GameClientBridgeQueryTemplateDeclaration, len(body.QueryTemplates))
for index, template := range body.QueryTemplates {
queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds}
queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds, Projections: gameClientBridgeQueryProjectionsToDomain(template.Projections)}
}
logProjections := make([]domain.GameClientBridgeLogProjectionDeclaration, len(body.LogProjections))
for index, projection := range body.LogProjections {
logProjections[index] = gameClientBridgeLogProjectionToDomain(projection)
}
lifecycleProjections := make([]domain.GameClientBridgeLifecycleProjectionDeclaration, len(body.LifecycleProjections))
for index, projection := range body.LifecycleProjections {
lifecycleProjections[index] = gameClientBridgeLifecycleProjectionToDomain(projection)
}
dataPacks := make([]domain.GameClientBridgeDataPackDeclaration, len(body.DataPacks))
for index, dataPack := range body.DataPacks {
dataPacks[index] = domain.GameClientBridgeDataPackDeclaration{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, LogParserRefs: domain.CopyStringSlice(dataPack.LogParserRefs), ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)}
@@ -1264,7 +1307,7 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
if body.Companion != nil {
companion = domain.GameClientBridgeCompanionDeclaration{ProfileKey: body.Companion.ProfileKey, ConfigTemplateKey: body.Companion.ConfigTemplateKey, ConfigSchemaRef: body.Companion.ConfigSchemaRef, ConfigFormat: body.Companion.ConfigFormat, PlatformBaseURLSource: body.Companion.PlatformBaseURLSource, RegistrationProof: body.Companion.RegistrationProof, ProofMaterialSource: body.Companion.ProofMaterialSource, ProofMaterialEnv: body.Companion.ProofMaterialEnv, SessionMode: body.Companion.SessionMode, TLSPolicy: body.Companion.TLSPolicy, HeartbeatIntervalSeconds: body.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: body.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: body.Companion.RequestTimeoutSeconds}
}
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, DataPacks: dataPacks, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion}
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion}
}
func gameClientBridgeLogProjectionToDomain(value GameClientBridgeLogProjectionDeclarationBody) domain.GameClientBridgeLogProjectionDeclaration {
@@ -1291,8 +1334,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, MergeExisting: value.MergeExisting}
}
return out
}
func gameClientBridgeLifecycleProjectionToDomain(value GameClientBridgeLifecycleProjectionDeclarationBody) domain.GameClientBridgeLifecycleProjectionDeclaration {
return domain.GameClientBridgeLifecycleProjectionDeclaration{Key: value.Key, Capabilities: domain.CopyStringSlice(value.Capabilities), ProcessStates: domain.CopyStringSlice(value.ProcessStates), Target: gameClientBridgeBulkProjectionTargetToDomain(value.Target)}
}
func gameClientBridgeBulkProjectionTargetToDomain(value GameClientBridgeBulkProjectionTargetBody) domain.GameClientBridgeBulkProjectionTargetDeclaration {
return domain.GameClientBridgeBulkProjectionTargetDeclaration{Collection: value.Collection, MatchField: value.MatchField, MatchValue: value.MatchValue, FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField, ActivityTarget: gameClientBridgeBulkActivityTargetToDomainPointer(value.ActivityTarget)}
}
func gameClientBridgeBulkActivityTargetToDomainPointer(value *GameClientBridgeBulkActivityTargetBody) *domain.GameClientBridgeBulkActivityTargetDeclaration {
if value == nil {
return nil
}
target := domain.GameClientBridgeBulkActivityTargetDeclaration{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), RowMappings: domain.CopyStringMap(value.RowMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
return &target
}
func gameClientBridgeLogProjectionTargetToDomain(value GameClientBridgeLogProjectionTargetDeclarationBody) domain.GameClientBridgeLogProjectionTargetDeclaration {
return domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
return domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), HashMappings: domain.CopyStringMap(value.HashMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
}
func gameClientBridgeLogProjectionTargetToDomainPointer(value *GameClientBridgeLogProjectionTargetDeclarationBody) *domain.GameClientBridgeLogProjectionTargetDeclaration {
@@ -1710,12 +1780,16 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
}
queryTemplates := make([]GameClientBridgeQueryTemplateDeclarationBody, len(value.QueryTemplates))
for index, template := range value.QueryTemplates {
queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds}
queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, SQLRef: template.SQLRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds, PollIntervalSeconds: template.PollIntervalSeconds, Projections: gameClientBridgeQueryProjectionsFromDomain(template.Projections)}
}
logProjections := make([]GameClientBridgeLogProjectionDeclarationBody, len(value.LogProjections))
for index, projection := range value.LogProjections {
logProjections[index] = gameClientBridgeLogProjectionFromDomain(projection)
}
lifecycleProjections := make([]GameClientBridgeLifecycleProjectionDeclarationBody, len(value.LifecycleProjections))
for index, projection := range value.LifecycleProjections {
lifecycleProjections[index] = gameClientBridgeLifecycleProjectionFromDomain(projection)
}
dataPacks := make([]GameClientBridgeDataPackDeclarationBody, len(value.DataPacks))
for index, dataPack := range value.DataPacks {
dataPacks[index] = GameClientBridgeDataPackDeclarationBody{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, LogParserRefs: domain.CopyStringSlice(dataPack.LogParserRefs), ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)}
@@ -1732,7 +1806,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
if value.Companion.ProfileKey != "" {
companion = &GameClientBridgeCompanionDeclarationBody{ProfileKey: value.Companion.ProfileKey, ConfigTemplateKey: value.Companion.ConfigTemplateKey, ConfigSchemaRef: value.Companion.ConfigSchemaRef, ConfigFormat: value.Companion.ConfigFormat, PlatformBaseURLSource: value.Companion.PlatformBaseURLSource, RegistrationProof: value.Companion.RegistrationProof, ProofMaterialSource: value.Companion.ProofMaterialSource, ProofMaterialEnv: value.Companion.ProofMaterialEnv, SessionMode: value.Companion.SessionMode, TLSPolicy: value.Companion.TLSPolicy, HeartbeatIntervalSeconds: value.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.Companion.RequestTimeoutSeconds}
}
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, DataPacks: dataPacks, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, LifecycleProjections: lifecycleProjections, DataPacks: dataPacks, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
}
func gameClientBridgeLogProjectionFromDomain(value domain.GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclarationBody {
@@ -1759,8 +1833,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, MergeExisting: value.MergeExisting}
}
return out
}
func gameClientBridgeLifecycleProjectionFromDomain(value domain.GameClientBridgeLifecycleProjectionDeclaration) GameClientBridgeLifecycleProjectionDeclarationBody {
return GameClientBridgeLifecycleProjectionDeclarationBody{Key: value.Key, Capabilities: domain.CopyStringSlice(value.Capabilities), ProcessStates: domain.CopyStringSlice(value.ProcessStates), Target: gameClientBridgeBulkProjectionTargetFromDomain(value.Target)}
}
func gameClientBridgeBulkProjectionTargetFromDomain(value domain.GameClientBridgeBulkProjectionTargetDeclaration) GameClientBridgeBulkProjectionTargetBody {
return GameClientBridgeBulkProjectionTargetBody{Collection: value.Collection, MatchField: value.MatchField, MatchValue: value.MatchValue, FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField, ActivityTarget: gameClientBridgeBulkActivityTargetFromDomainPointer(value.ActivityTarget)}
}
func gameClientBridgeBulkActivityTargetFromDomainPointer(value *domain.GameClientBridgeBulkActivityTargetDeclaration) *GameClientBridgeBulkActivityTargetBody {
if value == nil {
return nil
}
target := GameClientBridgeBulkActivityTargetBody{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), RowMappings: domain.CopyStringMap(value.RowMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
return &target
}
func gameClientBridgeLogProjectionTargetFromDomain(value domain.GameClientBridgeLogProjectionTargetDeclaration) GameClientBridgeLogProjectionTargetDeclarationBody {
return GameClientBridgeLogProjectionTargetDeclarationBody{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
return GameClientBridgeLogProjectionTargetDeclarationBody{Collection: value.Collection, UpsertKeys: domain.CopyStringSlice(value.UpsertKeys), CaptureMappings: domain.CopyStringMap(value.CaptureMappings), HashMappings: domain.CopyStringMap(value.HashMappings), FixedValues: domain.CopyStringMap(value.FixedValues), ObservedAtField: value.ObservedAtField}
}
func gameClientBridgeLogProjectionTargetFromDomainPointer(value *domain.GameClientBridgeLogProjectionTargetDeclaration) *GameClientBridgeLogProjectionTargetDeclarationBody {
+27 -3
View File
@@ -137,12 +137,14 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
QueryTemplates: []GameClientBridgeQueryTemplateDeclarationBody{{
Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite",
ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", SQLRef: "sql/player-lookup.sql", MaxRows: 50, TimeoutSeconds: 10, PollIntervalSeconds: 3,
Projections: []GameClientBridgeQueryProjectionDeclarationBody{{Collection: "users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt", MergeExisting: true}},
}},
LogProjections: []GameClientBridgeLogProjectionDeclarationBody{{
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"}}},
}},
LifecycleProjections: []GameClientBridgeLifecycleProjectionDeclarationBody{{Key: "server.stop", Capabilities: []string{"process.stop"}, Target: GameClientBridgeBulkProjectionTargetBody{Collection: "users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false"}, ObservedAtField: "lastLogoutAt", ActivityTarget: &GameClientBridgeBulkActivityTargetBody{Collection: "activity", UpsertKeys: []string{"steamId", "observedAt"}, RowMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"eventType": "logout"}, ObservedAtField: "observedAt"}}}},
DataPacks: []GameClientBridgeDataPackDeclarationBody{{Key: "db-v1", DatabaseUserVersion: 1, LogParserRefs: []string{"data/logs.json"}, ConfigMapRefs: []string{"data/config.json"}, DataRefs: []string{"data/items.json"}}},
CommandRetentionSeconds: 86400,
MaxCommands: 1000,
@@ -150,14 +152,24 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
}
domainManifest := body.ToDomain()
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || len(domainManifest.QueryTemplates[0].Projections) != 1 || domainManifest.QueryTemplates[0].Projections[0].MatchValue != "player" || !domainManifest.QueryTemplates[0].Projections[0].MergeExisting || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || domainManifest.LogProjections[0].Target.HashMappings["networkCorrelation"] != "ip" || len(domainManifest.LifecycleProjections) != 1 || domainManifest.LifecycleProjections[0].Target.ActivityTarget.FixedValues["eventType"] != "logout" || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest)
}
domainManifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "mutated"
if body.QueryTemplates[0].Projections[0].FieldMappings["steamId"] != "steamId" {
t.Fatal("query projection target aliases request DTO data")
}
domainManifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "steamId"
domainManifest.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated"
if body.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" {
t.Fatal("log projection target aliases request DTO data")
}
domainManifest.LogProjections[0].Target.CaptureMappings["steamId"] = "steamId"
domainManifest.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] = "mutated"
if body.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] != "steamId" {
t.Fatal("lifecycle projection target aliases request DTO data")
}
domainManifest.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] = "steamId"
domainManifest.Pages[0].QueryTemplateKeys[0] = "mutated"
if body.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
t.Fatal("query template page keys alias request DTO data")
@@ -170,10 +182,22 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
domainManifest.DataPacks[0].DataRefs[0] = "data/items.json"
response := gameClientBridgeManifestFromDomain(domainManifest)
if !response.QueryTemplates[0].Projections[0].MergeExisting {
t.Fatal("query projection mergeExisting was not preserved")
}
response.QueryTemplates[0].Projections[0].FixedValues["source"] = "mutated"
if domainManifest.QueryTemplates[0].Projections[0].FixedValues["source"] != "sqlite" {
t.Fatal("query projection target aliases domain data")
}
response.QueryTemplates[0].Projections[0].FixedValues["source"] = "sqlite"
response.LogProjections[0].Target.FixedValues["source"] = "mutated"
if domainManifest.LogProjections[0].Target.FixedValues["source"] != "stdout" {
t.Fatal("log projection target aliases domain data")
}
response.LifecycleProjections[0].Target.FixedValues["online"] = "mutated"
if domainManifest.LifecycleProjections[0].Target.FixedValues["online"] != "false" {
t.Fatal("lifecycle projection target aliases domain data")
}
response.Pages[0].QueryTemplateKeys[0] = "mutated"
if domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
t.Fatal("query template page keys alias domain data")
@@ -187,7 +211,7 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
if err := json.Unmarshal(encoded, &projection); err != nil {
t.Fatalf("decode safe query template projection: %v", err)
}
expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "sqlRef", "maxRows", "timeoutSeconds", "pollIntervalSeconds"}
expectedFields := []string{"key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "sqlRef", "maxRows", "timeoutSeconds", "pollIntervalSeconds", "projections"}
if len(projection) != len(expectedFields) {
t.Fatalf("query template projection contains unexpected fields: %s", encoded)
}
+14 -1
View File
@@ -50,7 +50,17 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
}
job, ok := firstEligibleSupportedJob(jobs, claim.Capabilities, stamp)
if !ok {
return emptyJobClaim(claim.RunEndpointID, stamp), nil
if err := svc.scheduleDuePluginQueryProjectionJobs(claim, stamp); err != nil {
return domain.RunJobClaimResult{}, err
}
jobs, err = svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID})
if err != nil {
return domain.RunJobClaimResult{}, err
}
job, ok = firstEligibleSupportedJob(jobs, claim.Capabilities, stamp)
if !ok {
return emptyJobClaim(claim.RunEndpointID, stamp), nil
}
}
leaseToken, err := randomToken()
@@ -342,6 +352,9 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo
if err := svc.projectPluginOperationsJobResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err
}
if err := svc.projectPluginQueryJobResult(job, stamp); err != nil {
return domain.RunJobResultResult{}, err
}
return domain.RunJobResultResult{Accepted: true, Job: assignmentFromJob(job, result.LeaseToken), ServerTime: stamp}, nil
}
+48
View File
@@ -156,3 +156,51 @@ func TestRunPollDoesNotSchedulePluginDataProjectionQueries(t *testing.T) {
t.Fatalf("automatic projection query persisted jobs=%+v err=%v", jobs, err)
}
}
func TestRunPollSchedulesAndProjectsDeclaredSQLiteQuery(t *testing.T) {
svc, plugin, endpoint, session, instance := createSQLiteQueryBridgeFixture(t)
plugin.GameClientBridge.QueryTemplates[0].PollIntervalSeconds = 3
plugin.GameClientBridge.QueryTemplates[0].Projections = []domain.GameClientBridgeQueryProjectionDeclaration{{
Collection: "scum_users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"},
FieldMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt",
}, {
Collection: "scum_trade_goods", RowPath: "rows", MatchField: "kind", MatchValue: "vehicle", UpsertKeys: []string{"code"},
FieldMappings: map[string]string{"className": "displayName"}, FixedValues: map[string]string{"code": "#spawnvehicle {{displayName}}", "spawnCommand": "#spawnvehicle {{displayName}}", "catalogType": "vehicle", "type": "21", "typeName": "其他载具", "imagePath": "/original/{{displayName}}.webp", "source": "sqlite"}, ObservedAtField: "lastSeenAt", MergeExisting: true,
}}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("enable query projection polling: %v", err)
}
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_goods", Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: "#spawnvehicle Truck", Value: map[string]any{"code": "#spawnvehicle Truck", "name": "Named Truck"}}}}); err != nil {
t.Fatalf("seed vehicle catalog: %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)
}
goods, err := svc.ListPluginDataForSession(session, domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_goods"})
if err != nil || len(goods) != 1 || goods[0].Key != "#spawnvehicle Truck" || goods[0].Value["name"] != "Named Truck" || goods[0].Value["className"] != "Truck" || goods[0].Value["type"] != "21" || goods[0].Value["lastSeenAt"] == nil {
t.Fatalf("declared vehicle catalog projection did not merge scoped plugin data=%+v err=%v", goods, 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
}
+15 -5
View File
@@ -1,6 +1,8 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"regexp"
@@ -18,7 +20,7 @@ type pluginLogSequenceState struct {
}
func (svc *CoreService) projectPluginLogBatch(stream domain.LogStream, entries []domain.LogEntry) error {
if stream.Source != domain.LogStreamSourceProcess || len(entries) == 0 {
if len(entries) == 0 || (stream.Source != domain.LogStreamSourceProcess && stream.Source != domain.LogStreamSourceFile && stream.Source != domain.LogStreamSourceManagementProgram) {
return nil
}
instance, err := svc.store.ServerInstances().Get(stream.ServerInstanceID)
@@ -160,7 +162,7 @@ func logCorrelationKey(captures map[string]string, fields []string) string {
}
func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance, plugin domain.GamePlugin, projection domain.GameClientBridgeLogProjectionDeclaration, captures map[string]string, observedAt time.Time) error {
value := pluginLogProjectionValue(projection.Target, captures, observedAt)
value := pluginLogProjectionValue(instance.ID, projection.Target, captures, observedAt)
key, err := pluginDataRowKey(value, projection.Target.UpsertKeys)
if err != nil {
return err
@@ -191,7 +193,7 @@ func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance,
return err
}
if projection.Presence != nil && projection.Presence.ActivityTarget != nil {
activity := pluginLogProjectionValue(*projection.Presence.ActivityTarget, captures, observedAt)
activity := pluginLogProjectionValue(instance.ID, *projection.Presence.ActivityTarget, captures, observedAt)
activityKey, keyErr := pluginDataRowKey(activity, projection.Presence.ActivityTarget.UpsertKeys)
if keyErr != nil {
return keyErr
@@ -203,11 +205,14 @@ func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance,
return nil
}
func pluginLogProjectionValue(target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]string, observedAt time.Time) map[string]any {
value := make(map[string]any, len(target.CaptureMappings)+len(target.FixedValues)+1)
func pluginLogProjectionValue(serverID string, target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]string, observedAt time.Time) map[string]any {
value := make(map[string]any, len(target.CaptureMappings)+len(target.HashMappings)+len(target.FixedValues)+1)
for destination, capture := range target.CaptureMappings {
value[destination] = captures[capture]
}
for destination, capture := range target.HashMappings {
value[destination] = logProjectionCorrelationHash(serverID, captures[capture])
}
for key, fixed := range target.FixedValues {
value[key] = renderLogProjectionTemplate(fixed, captures)
}
@@ -217,6 +222,11 @@ func pluginLogProjectionValue(target domain.GameClientBridgeLogProjectionTargetD
return value
}
func logProjectionCorrelationHash(serverID, value string) string {
digest := sha256.Sum256([]byte(serverID + "\x00" + value))
return hex.EncodeToString(digest[:])
}
func renderLogProjectionTemplate(template string, captures map[string]string) string {
result := template
for key, value := range captures {
@@ -71,6 +71,129 @@ func TestDurableStdoutProjectionCreatesUsersAndSuppressesRapidDuplicates(t *test
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 2, 0)
}
func TestTradeLogProjectionsCreateCatalogAndTradeEvents(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
pattern := `^\d{4}\.\d{2}\.\d{2}-\d{2}\.\d{2}\.\d{2}: \[Trade\] Tradeable \((?P<itemCode>[A-Za-z0-9_.-]{1,128}) \(x(?P<quantity>\d{1,9})\)\) (?P<tradeVerb>purchased|sold) by .*?\((?P<steamId>\d{1,50})\) for (?P<price>-?\d{1,12})$`
plugin.GameClientBridge.LogProjections = []domain.GameClientBridgeLogProjectionDeclaration{{
Key: "scum.trade.catalog", StreamKeys: []string{"scum.trade"}, Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{{Pattern: pattern}}, CorrelationFields: []string{"itemCode"}, MaxInterveningLines: 0,
Target: domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_trade_goods", UpsertKeys: []string{"code"}, CaptureMappings: map[string]string{"code": "itemCode"}, FixedValues: map[string]string{"catalogType": "item", "source": "scum.trade"}, ObservedAtField: "lastSeenAt"},
}, {
Key: "scum.trade.events", StreamKeys: []string{"scum.trade"}, Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{{Pattern: pattern}}, CorrelationFields: []string{"steamId", "itemCode", "tradeVerb"}, MaxInterveningLines: 0,
Target: domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_trade_events", UpsertKeys: []string{"steamId", "itemCode", "tradeVerb", "quantity", "price", "observedAt"}, CaptureMappings: map[string]string{"steamId": "steamId", "itemCode": "itemCode", "tradeVerb": "tradeVerb", "quantity": "quantity", "price": "price"}, FixedValues: map[string]string{"eventType": "trade", "source": "scum.trade"}, ObservedAtField: "observedAt"},
}}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update trade projections: %v", err)
}
instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-trade-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM trade projection", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create server: %v", err)
}
helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Fingerprint = "cap-trade-log-projection"
hello, err := svc.RegisterRunHello(helloRequest)
if err != nil {
t.Fatalf("register Run: %v", err)
}
stream, err := svc.CreateLogStream(domain.LogStream{ID: "trade-log-projection", ServerInstanceID: instance.ID, Source: domain.LogStreamSourceFile, StreamKey: "scum.trade", StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"})
if err != nil {
t.Fatalf("create trade stream: %v", err)
}
base := time.Date(2026, 8, 27, 12, 34, 56, 0, time.UTC)
ingestTradeProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 1, base, []string{`2026.08.27-12.34.56: [Trade] Tradeable (BPC_Apple (x2)) purchased by Mira(76561198000000001) for 120`})
goods, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_goods"})
if err != nil || len(goods) != 1 || goods[0].Key != "BPC_Apple" || goods[0].Value["catalogType"] != "item" || goods[0].Value["lastSeenAt"] == nil {
t.Fatalf("trade item catalog was not projected: %+v err=%v", goods, err)
}
trades, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_events"})
if err != nil || len(trades) != 1 || trades[0].Value["itemCode"] != "BPC_Apple" || trades[0].Value["quantity"] != "2" || trades[0].Value["tradeVerb"] != "purchased" || trades[0].Value["steamId"] != "76561198000000001" || trades[0].Value["price"] != "120" {
t.Fatalf("trade event was not projected: %+v err=%v", trades, err)
}
}
func TestLifecycleProjectionMarksOnlineUsersOffline(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.GameClientBridge.LifecycleProjections = []domain.GameClientBridgeLifecycleProjectionDeclaration{{
Key: "server.stop", Capabilities: []string{domain.LifecycleCapabilityStop}, ProcessStates: []string{"stopped"},
Target: domain.GameClientBridgeBulkProjectionTargetDeclaration{Collection: "scum_users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false", "status": "offline", "logoutReason": "server-stop"}, ObservedAtField: "lastLogoutAt", ActivityTarget: &domain.GameClientBridgeBulkActivityTargetDeclaration{Collection: "scum_activity_events", UpsertKeys: []string{"steamId", "observedAt", "eventType"}, RowMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"eventType": "logout", "reason": "server-stop"}, ObservedAtField: "observedAt"}},
}}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update lifecycle projection plugin: %v", err)
}
instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-lifecycle-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM lifecycle", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create server: %v", err)
}
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users", Mutations: []domain.PluginDataMutation{
{Operation: domain.PluginDataMutationPut, Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "displayName": "Ada", "online": "true"}},
{Operation: domain.PluginDataMutationPut, Key: "steam-2", Value: map[string]any{"steamId": "steam-2", "displayName": "Lin", "online": "false"}},
}}); err != nil {
t.Fatalf("seed plugin users: %v", err)
}
helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.LifecycleCapabilityStop)
helloRequest.CapabilityReport.Fingerprint = "cap-lifecycle-projection"
hello, err := svc.RegisterRunHello(helloRequest)
if err != nil {
t.Fatalf("register Run: %v", err)
}
_, err = svc.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, ServerInstanceID: instance.ID, Capability: domain.LifecycleCapabilityStop, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop"}})
if err != nil {
t.Fatalf("report lifecycle stop: %v", err)
}
users, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"})
if err != nil || len(users) != 2 {
t.Fatalf("list lifecycle users=%+v err=%v", users, err)
}
for _, user := range users {
if user.Key == "steam-1" && (user.Value["online"] != "false" || user.Value["logoutReason"] != "server-stop" || user.Value["lastLogoutAt"] == nil) {
t.Fatalf("online user was not logged out: %+v", user)
}
if user.Key == "steam-2" && user.Value["logoutReason"] != nil {
t.Fatalf("offline user should not receive duplicate logout: %+v", user)
}
}
activity, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_activity_events"})
if err != nil || len(activity) != 1 || activity[0].Value["steamId"] != "steam-1" || activity[0].Value["eventType"] != "logout" {
t.Fatalf("lifecycle logout activity not projected: %+v err=%v", activity, err)
}
}
func TestLifecycleRestartReportMarksOnlineUsersOffline(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.GameClientBridge.LifecycleProjections = []domain.GameClientBridgeLifecycleProjectionDeclaration{{
Key: "server.restart", Capabilities: []string{"process.restart"},
Target: domain.GameClientBridgeBulkProjectionTargetDeclaration{Collection: "scum_users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false", "status": "offline", "logoutReason": "server-stop"}, ObservedAtField: "lastLogoutAt"},
}}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update restart projection plugin: %v", err)
}
instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-restart-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM restart", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create server: %v", err)
}
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users", Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "displayName": "Ada", "online": true}}}}); err != nil {
t.Fatalf("seed plugin users: %v", err)
}
helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.restart")
helloRequest.CapabilityReport.Fingerprint = "cap-restart-lifecycle-projection"
hello, err := svc.RegisterRunHello(helloRequest)
if err != nil {
t.Fatalf("register Run: %v", err)
}
report, err := svc.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, ServerInstanceID: instance.ID, Capability: "process.restart", State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running"}})
if err != nil || report.ProjectedState != domain.ServerInstanceStateRunning {
t.Fatalf("report lifecycle restart: report=%+v err=%v", report, err)
}
users, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"})
if err != nil || len(users) != 1 || users[0].Value["online"] != "false" || users[0].Value["logoutReason"] != "server-stop" || users[0].Value["lastLogoutAt"] == nil {
t.Fatalf("restart did not log out online users: %+v err=%v", users, err)
}
}
func ingestProjectionLines(t *testing.T, svc *CoreService, sessionToken, endpointID, serverID, streamID string, firstSeq uint64, observedAt time.Time, lines []string) {
t.Helper()
entries := make([]domain.LogEntry, len(lines))
@@ -84,6 +207,19 @@ func ingestProjectionLines(t *testing.T, svc *CoreService, sessionToken, endpoin
}
}
func ingestTradeProjectionLines(t *testing.T, svc *CoreService, sessionToken, endpointID, serverID, streamID string, firstSeq uint64, observedAt time.Time, lines []string) {
t.Helper()
entries := make([]domain.LogEntry, len(lines))
for index, line := range lines {
entries[index] = domain.LogEntry{Seq: firstSeq + uint64(index), Timestamp: observedAt.Add(time.Duration(index) * time.Second), Level: "info", Line: line}
}
lastSeq := firstSeq + uint64(len(entries)) - 1
batch := domain.LogBatchIngest{RunEndpointID: endpointID, SessionToken: sessionToken, LogStreamID: streamID, ServerInstanceID: serverID, StreamKey: "scum.trade", Source: domain.LogStreamSourceFile, FirstSeq: firstSeq, LastSeq: lastSeq, Compression: "none", Checksum: checksumForEntries(t, entries), Entries: entries}
if result, err := svc.IngestLogBatch(batch); err != nil || !result.Accepted {
t.Fatalf("ingest trade projection lines result=%+v err=%v", result, err)
}
}
func assertPresenceProjectionCounts(t *testing.T, svc *CoreService, pluginID, serverID string, users, activities, commands int) {
t.Helper()
userRows, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: pluginID, ServerInstanceID: serverID, Collection: "scum_users"})
+224
View File
@@ -0,0 +1,224 @@
package service
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
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
}
if projection.MergeExisting {
if existing, existingErr := svc.store.PluginDataRecords().Get(pluginDataID(instance.ID, plugin.ID, projection.Collection, key)); existingErr == nil {
value = mergePluginDataValues(existing.Value, value)
} else if !errors.Is(existingErr, repo.ErrNotFound) {
return existingErr
}
}
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()
nextState, projected := lifecycleProjectedState(report.Capability, report.State, report.ExecutionResult)
if lifecycleObservationIsStale(instance, report) {
staleObservation := lifecycleObservationIsStale(instance, report)
if !projected || staleObservation {
projected = false
nextState = instance.State
}
@@ -52,6 +53,15 @@ func (svc *CoreService) ReportRunLifecycle(report domain.RunLifecycleReport) (do
}
svc.publishLogProcessState(instance)
}
if report.State == domain.JobStateSucceeded && !staleObservation {
plugin, pluginErr := svc.store.GamePlugins().Get(instance.PluginID)
if pluginErr != nil {
return domain.RunLifecycleReportResult{}, pluginErr
}
if err := svc.projectPluginLifecycleState(instance, plugin, report.Capability, report.ExecutionResult, stamp); err != nil {
return domain.RunLifecycleReportResult{}, err
}
}
return domain.CopyRunLifecycleReportResult(domain.RunLifecycleReportResult{Accepted: true, RunEndpointID: report.RunEndpointID, ServerInstanceID: report.ServerInstanceID, ProjectedState: nextState, ServerTime: stamp}), nil
}
@@ -97,6 +107,9 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim
}
nextState, ok := lifecycleProjectedState(job.Capability, job.State, job.ExecutionResult)
if !ok || job.ServerInstanceID == "" {
if job.State == domain.JobStateSucceeded && job.ServerInstanceID != "" {
return svc.projectPluginLifecycleStateForJob(job, stamp)
}
return nil
}
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
@@ -112,9 +125,30 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim
return err
}
svc.publishLogProcessState(instance)
if job.State == domain.JobStateSucceeded {
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return err
}
if err := svc.projectPluginLifecycleState(instance, plugin, job.Capability, job.ExecutionResult, stamp); err != nil {
return err
}
}
return nil
}
func (svc *CoreService) projectPluginLifecycleStateForJob(job domain.Job, stamp time.Time) error {
instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID)
if err != nil {
return err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return err
}
return svc.projectPluginLifecycleState(instance, plugin, job.Capability, job.ExecutionResult, stamp)
}
func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp time.Time) error {
if job.ExecutionInput.Deployment == nil || job.ServerInstanceID == "" {
return nil
@@ -36,7 +36,7 @@ func validGameClientBridgeCompanionManifest() (domain.GameClientBridgeManifest,
func TestValidateGameClientBridgeCompanionDeclaration(t *testing.T) {
bridge, profiles := validGameClientBridgeCompanionManifest()
if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, nil, nil, profiles); len(violations) != 0 {
if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, nil, nil, nil, profiles); len(violations) != 0 {
t.Fatalf("expected valid companion declaration, got %v", violations)
}
@@ -74,7 +74,7 @@ func TestValidateGameClientBridgeCompanionDeclaration(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
candidateBridge, candidateProfiles := validGameClientBridgeCompanionManifest()
test.mutate(&candidateBridge, &candidateProfiles)
violations := validateGameClientBridgeManifest("gameClientBridge", candidateBridge, nil, nil, candidateProfiles)
violations := validateGameClientBridgeManifest("gameClientBridge", candidateBridge, nil, nil, nil, candidateProfiles)
if !strings.Contains(strings.Join(violations, "; "), test.expected) {
t.Fatalf("expected %q violation, got %v", test.expected, violations)
}
@@ -24,7 +24,7 @@ func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
}
profiles := domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{"game-client.bridge"}}}}}
if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, []string{"server.game-client.command"}, nil, profiles); len(violations) != 0 {
if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, []string{"server.game-client.command"}, nil, nil, profiles); len(violations) != 0 {
t.Fatalf("expected repeated named captures across steps to validate, got %v", violations)
}
@@ -52,7 +52,7 @@ func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
candidateProfiles := profiles
candidateProfiles.ClientManagers = append([]domain.RuntimeClientManagerProfile(nil), profiles.ClientManagers...)
test.mutate(&candidate, &candidateProfiles)
violations := validateGameClientBridgeManifest("gameClientBridge", candidate, []string{"server.game-client.command"}, nil, candidateProfiles)
violations := validateGameClientBridgeManifest("gameClientBridge", candidate, []string{"server.game-client.command"}, nil, nil, candidateProfiles)
if !strings.Contains(strings.Join(violations, "; "), test.expected) {
t.Fatalf("expected %q violation, got %v", test.expected, violations)
}
+2 -2
View File
@@ -65,7 +65,7 @@ func ValidateRunLifecycleReport(report domain.RunLifecycleReport) error {
violations = appendRequired(violations, "serverInstanceId", report.ServerInstanceID)
violations = appendRequired(violations, "capability", report.Capability)
if !validLifecycleReportCapability(report.Capability) {
violations = append(violations, "capability must be process.install, process.start, process.stop, or process.status")
violations = append(violations, "capability must be process.install, process.start, process.stop, process.restart, or process.status")
}
if !validTerminalJobState(report.State) {
violations = append(violations, "state must be succeeded, failed, or cancelled")
@@ -210,7 +210,7 @@ func validTerminalJobState(state domain.JobState) bool {
func validLifecycleReportCapability(capability string) bool {
switch capability {
case domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, domain.LifecycleCapabilityStatus:
case domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop, "process.restart", domain.LifecycleCapabilityStatus:
return true
default:
return false
+205 -4
View File
@@ -167,7 +167,7 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error {
}
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...)
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("runtimeProfiles.logEvents", plugin.RuntimeProfiles, plugin.DeclaredPermissions)...)
violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.Pages, plugin.RuntimeProfiles)...)
violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.RequiredRunCapabilities, plugin.Pages, plugin.RuntimeProfiles)...)
violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...)
violations = append(violations, validatePluginAssetFiles("lifecycleAssets", plugin.LifecycleAssets)...)
violations = append(violations, validateSafePluginStrings("gamePlugin", pluginSafeStrings(plugin))...)
@@ -238,7 +238,7 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
}
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(manifest.RuntimeProfiles, manifest.Capabilities)...)
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("manifest.runtimeProfiles.logEvents", manifest.RuntimeProfiles, manifest.Permissions)...)
violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Pages, manifest.RuntimeProfiles)...)
violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Capabilities, manifest.Pages, manifest.RuntimeProfiles)...)
violations = append(violations, validatePluginAssetFileDeclarations("manifest.assetFiles", manifest.AssetFiles)...)
violations = append(violations, validatePluginAssetFiles("assetFiles", registration.AssetFiles)...)
violations = append(violations, validateRegistrationAssetCoverage(registration.Manifest.AssetFiles, registration.AssetFiles)...)
@@ -467,9 +467,9 @@ func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[st
return finish(violations)
}
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, runCapabilities []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{})
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.LogProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.LogProjections) == 0 && len(bridge.LifecycleProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
return nil
}
var violations []string
@@ -610,6 +610,12 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
if template.SQLRef != "" && !safeRelativeSQLRef(template.SQLRef) {
violations = append(violations, prefix+".sqlRef must reference a package-relative SQL asset")
}
if len(template.Projections) > 4 {
violations = append(violations, prefix+".projections must not exceed 4 targets")
}
for projectionIndex, projection := range template.Projections {
violations = append(violations, validateGameClientBridgeQueryProjection(fmt.Sprintf("%s.projections[%d]", prefix, projectionIndex), projection)...)
}
transport, exists := transports[template.TransportKey]
if !exists {
violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile")
@@ -634,6 +640,18 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
logProjectionKeys[projection.Key] = struct{}{}
violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection)...)
}
lifecycleProjectionKeys := map[string]struct{}{}
for index, projection := range bridge.LifecycleProjections {
prefix := fmt.Sprintf("%s.lifecycleProjections[%d]", field, index)
if !clientManagerIdentifierPattern.MatchString(projection.Key) {
violations = append(violations, prefix+".key is invalid")
}
if _, exists := lifecycleProjectionKeys[projection.Key]; exists {
violations = append(violations, prefix+".key is duplicated")
}
lifecycleProjectionKeys[projection.Key] = struct{}{}
violations = append(violations, validateGameClientBridgeLifecycleProjection(prefix, projection, runCapabilities)...)
}
dataPackKeys := map[string]struct{}{}
for index, dataPack := range bridge.DataPacks {
prefix := fmt.Sprintf("%s.dataPacks[%d]", field, index)
@@ -753,6 +771,174 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
return violations
}
func validateGameClientBridgeQueryProjection(prefix string, projection domain.GameClientBridgeQueryProjectionDeclaration) []string {
var violations []string
if !gameClientBridgeCollectionPattern.MatchString(projection.Collection) {
violations = append(violations, prefix+".collection is invalid")
}
if projection.RowPath != "rows" {
violations = append(violations, prefix+".rowPath must be rows")
}
if projection.MatchField != "" && !gameClientBridgeFieldPattern.MatchString(projection.MatchField) {
violations = append(violations, prefix+".matchField is invalid")
}
if projection.MatchField == "" && projection.MatchValue != "" || projection.MatchField != "" && strings.TrimSpace(projection.MatchValue) == "" {
violations = append(violations, prefix+".matchField and matchValue must be declared together")
}
if len([]rune(projection.MatchValue)) > 120 {
violations = append(violations, prefix+".matchValue is too long")
}
if len(projection.UpsertKeys) < 1 || len(projection.UpsertKeys) > 8 {
violations = append(violations, prefix+".upsertKeys must contain between 1 and 8 fields")
}
projectedFields := map[string]struct{}{}
for destination, source := range projection.FieldMappings {
if !gameClientBridgeFieldPattern.MatchString(destination) || !gameClientBridgeFieldPattern.MatchString(source) {
violations = append(violations, prefix+".fieldMappings contains an invalid field")
}
projectedFields[destination] = struct{}{}
}
if len(projection.FixedValues) > 64 {
violations = append(violations, prefix+".fixedValues contains too many fields")
}
for destination, value := range projection.FixedValues {
if !gameClientBridgeFieldPattern.MatchString(destination) || len([]rune(value)) > 4096 {
violations = append(violations, prefix+".fixedValues contains an invalid field or oversized value")
}
if _, exists := projectedFields[destination]; exists {
violations = append(violations, prefix+" declares field "+destination+" more than once")
}
projectedFields[destination] = struct{}{}
}
if projection.ObservedAtField != "" {
if !gameClientBridgeFieldPattern.MatchString(projection.ObservedAtField) {
violations = append(violations, prefix+".observedAtField is invalid")
}
if _, exists := projectedFields[projection.ObservedAtField]; exists {
violations = append(violations, prefix+" declares field "+projection.ObservedAtField+" more than once")
}
projectedFields[projection.ObservedAtField] = struct{}{}
}
for _, key := range projection.UpsertKeys {
if !gameClientBridgeFieldPattern.MatchString(key) {
violations = append(violations, prefix+".upsertKeys contains an invalid field")
}
if len(projection.FieldMappings) > 0 {
if _, exists := projectedFields[key]; !exists {
violations = append(violations, prefix+".upsertKeys field "+key+" is not projected")
}
}
}
violations = append(violations, duplicateViolations(prefix+".upsertKeys", projection.UpsertKeys)...)
return violations
}
func validateGameClientBridgeLifecycleProjection(prefix string, projection domain.GameClientBridgeLifecycleProjectionDeclaration, pluginRunCapabilities []string) []string {
var violations []string
if len(projection.Capabilities) < 1 || len(projection.Capabilities) > 16 {
violations = append(violations, prefix+".capabilities must contain between 1 and 16 values")
}
for _, capability := range projection.Capabilities {
if !validPluginRunCapability(capability) {
violations = append(violations, prefix+".capabilities contains an invalid capability")
}
if !containsString(pluginRunCapabilities, capability) {
violations = append(violations, prefix+".capabilities must be declared by the plugin")
}
}
violations = append(violations, duplicateViolations(prefix+".capabilities", projection.Capabilities)...)
if len(projection.ProcessStates) > 8 {
violations = append(violations, prefix+".processStates must not exceed 8")
}
for _, state := range projection.ProcessStates {
if !oneOf(state, "running", "stopped", "not-started", "exited") {
violations = append(violations, prefix+".processStates contains an invalid process state")
}
}
violations = append(violations, duplicateViolations(prefix+".processStates", projection.ProcessStates)...)
violations = append(violations, validateGameClientBridgeBulkProjectionTarget(prefix+".target", projection.Target)...)
return violations
}
func validateGameClientBridgeBulkProjectionTarget(prefix string, target domain.GameClientBridgeBulkProjectionTargetDeclaration) []string {
var violations []string
if !gameClientBridgeCollectionPattern.MatchString(target.Collection) {
violations = append(violations, prefix+".collection is invalid")
}
if !gameClientBridgeFieldPattern.MatchString(target.MatchField) {
violations = append(violations, prefix+".matchField is invalid")
}
if strings.TrimSpace(target.MatchValue) == "" || len([]rune(target.MatchValue)) > 120 {
violations = append(violations, prefix+".matchValue is invalid")
}
if len(target.FixedValues) < 1 || len(target.FixedValues) > 64 {
violations = append(violations, prefix+".fixedValues must contain between 1 and 64 fields")
}
for destination, value := range target.FixedValues {
if !gameClientBridgeFieldPattern.MatchString(destination) || len([]rune(value)) > 4096 {
violations = append(violations, prefix+".fixedValues contains an invalid field or oversized value")
}
}
if target.ObservedAtField != "" && !gameClientBridgeFieldPattern.MatchString(target.ObservedAtField) {
violations = append(violations, prefix+".observedAtField is invalid")
}
if target.ActivityTarget != nil {
violations = append(violations, validateGameClientBridgeBulkActivityTarget(prefix+".activityTarget", *target.ActivityTarget)...)
}
return violations
}
func validateGameClientBridgeBulkActivityTarget(prefix string, target domain.GameClientBridgeBulkActivityTargetDeclaration) []string {
var violations []string
if !gameClientBridgeCollectionPattern.MatchString(target.Collection) {
violations = append(violations, prefix+".collection is invalid")
}
if len(target.RowMappings) < 1 || len(target.RowMappings) > 64 {
violations = append(violations, prefix+".rowMappings must contain between 1 and 64 mappings")
}
projectedFields := map[string]struct{}{}
for destination, source := range target.RowMappings {
if !gameClientBridgeFieldPattern.MatchString(destination) || !gameClientBridgeFieldPattern.MatchString(source) {
violations = append(violations, prefix+".rowMappings contains an invalid field")
}
projectedFields[destination] = struct{}{}
}
if len(target.FixedValues) > 64 {
violations = append(violations, prefix+".fixedValues contains too many fields")
}
for destination, value := range target.FixedValues {
if !gameClientBridgeFieldPattern.MatchString(destination) || len([]rune(value)) > 4096 {
violations = append(violations, prefix+".fixedValues contains an invalid field or oversized value")
}
if _, exists := projectedFields[destination]; exists {
violations = append(violations, prefix+" declares field "+destination+" more than once")
}
projectedFields[destination] = struct{}{}
}
if target.ObservedAtField != "" {
if !gameClientBridgeFieldPattern.MatchString(target.ObservedAtField) {
violations = append(violations, prefix+".observedAtField is invalid")
}
if _, exists := projectedFields[target.ObservedAtField]; exists {
violations = append(violations, prefix+" declares field "+target.ObservedAtField+" more than once")
}
projectedFields[target.ObservedAtField] = struct{}{}
}
if len(target.UpsertKeys) < 1 || len(target.UpsertKeys) > 8 {
violations = append(violations, prefix+".upsertKeys must contain between 1 and 8 fields")
}
for _, key := range target.UpsertKeys {
if !gameClientBridgeFieldPattern.MatchString(key) {
violations = append(violations, prefix+".upsertKeys contains an invalid field")
}
if _, exists := projectedFields[key]; !exists {
violations = append(violations, prefix+".upsertKeys field "+key+" is not projected")
}
}
violations = append(violations, duplicateViolations(prefix+".upsertKeys", target.UpsertKeys)...)
return violations
}
func validateGameClientBridgeLogProjection(prefix string, projection domain.GameClientBridgeLogProjectionDeclaration) []string {
var violations []string
if len(projection.StreamKeys) < 1 || len(projection.StreamKeys) > 64 {
@@ -852,6 +1038,21 @@ func validateGameClientBridgeLogProjectionTarget(prefix string, target domain.Ga
}
projectedFields[destination] = struct{}{}
}
if len(target.HashMappings) > 64 {
violations = append(violations, prefix+".hashMappings contains too many fields")
}
for destination, capture := range target.HashMappings {
if !gameClientBridgeFieldPattern.MatchString(destination) || !gameClientBridgeCaptureNamePattern.MatchString(capture) {
violations = append(violations, prefix+".hashMappings contains an invalid field or capture")
}
if _, exists := captures[capture]; !exists {
violations = append(violations, prefix+".hashMappings references undeclared capture "+capture)
}
if _, exists := projectedFields[destination]; exists {
violations = append(violations, prefix+" declares field "+destination+" more than once")
}
projectedFields[destination] = struct{}{}
}
if len(target.FixedValues) > 64 {
violations = append(violations, prefix+".fixedValues contains too many fields")
}
+14
View File
@@ -51,6 +51,20 @@ export interface GameClientBridgeQueryTemplateDeclarationResponse {
sqlRef?: string;
maxRows: number;
timeoutSeconds: number;
pollIntervalSeconds?: number;
projections?: GameClientBridgeQueryProjectionDeclarationResponse[];
}
export interface GameClientBridgeQueryProjectionDeclarationResponse {
collection: string;
rowPath: "rows";
matchField?: string;
matchValue?: string;
upsertKeys: string[];
fieldMappings?: Record<string, string>;
fixedValues?: Record<string, string>;
observedAtField?: string;
mergeExisting?: boolean;
}
export interface GameClientBridgeDataPackDeclarationResponse { key: string; databaseUserVersion: number; logParserRefs: string[]; configMapRefs: string[]; }
+15
View File
@@ -169,6 +169,21 @@ export function ServerDetailPage(props: PageComponentProps) {
return (
<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 === "error" && (
<ErrorState title="服务器详情加载失败" reason={instance.reason} diagnosticId={`server-detail:${serverId}`} onRetry={() => void refresh()} />
+1 -1
View File
@@ -778,7 +778,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.console-stat-strip>div,.operations-pulse-strip>div{display:grid;gap:3px;min-width:0;padding:9px 10px;border:1px solid color-mix(in srgb,var(--line) 78%,transparent);border-radius:6px;background:color-mix(in srgb,var(--surface-solid) 78%,var(--accent-soft))}
.console-stat-strip dt,.operations-pulse-strip dt{color:var(--ink-faint);font-size:11px}
.console-stat-strip dd,.operations-pulse-strip dd{margin:0;color:var(--ink);font-size:18px;font-weight:850}
.map-projection-board{position:relative;min-height:320px;border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d)}.map-projection-dot{position:absolute;width:9px;height:9px;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%)}
.map-projection-board{position:relative;min-height:320px;border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d);background-size:cover;background-position:center}.map-grid-overlay{position:absolute;inset:0;z-index:1;pointer-events:none}.map-grid-line{position:absolute;background:color-mix(in srgb,var(--line) 62%,transparent)}.map-grid-line-v{top:0;bottom:0;width:1px}.map-grid-line-h{left:0;right:0;height:1px}.map-grid-label{position:absolute;transform:translate(-50%,-50%);padding:1px 5px;border:1px solid color-mix(in srgb,var(--line) 70%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent);color:var(--ink);font:800 10px/1 var(--font-mono);text-shadow:0 1px 4px rgba(0,0,0,.55)}.map-grid-col-label{top:10px}.map-grid-row-label{left:12px}.map-projection-dot{position:absolute;z-index:3;width:9px;height:9px;padding:0;border:0;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%);cursor:pointer}.map-projection-dot img{display:block;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 0 8px color-mix(in srgb,var(--accent) 76%,transparent))}.map-projection-dot.map-layer-vehicles{width:26px;height:26px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.map-projection-dot.map-layer-flags{background:var(--gold)}.map-projection-dot.map-layer-regions{background:var(--success)}.map-projection-dot-riding{outline:2px solid var(--gold);box-shadow:0 0 0 4px color-mix(in srgb,var(--gold) 24%,transparent),0 0 18px color-mix(in srgb,var(--gold) 80%,transparent)}.map-trajectory-dot{position:absolute;z-index:2;width:4px;height:4px;border-radius:999px;background:color-mix(in srgb,var(--accent) 82%,transparent);box-shadow:0 0 8px color-mix(in srgb,var(--accent) 66%,transparent);transform:translate(-50%,-50%);pointer-events:none}.map-trajectory-dot.map-layer-vehicles{width:5px;height:5px;background:color-mix(in srgb,var(--gold) 86%,transparent)}
.console-row-list,.operations-endpoint-list,.operations-job-list{display:grid;gap:6px;margin-top:10px}
.console-row,.operations-endpoint-row,.operations-job-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:10px;min-width:0;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:var(--control-surface);color:var(--ink-soft);text-align:left}
.console-row-button,.operations-job-row{width:100%;cursor:pointer}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -3,10 +3,13 @@ package companion
import (
"crypto/sha256"
"encoding/hex"
"regexp"
"strings"
"time"
)
var scumLoginLogLine = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}-\d{2}\.\d{2}\.\d{2}: '([0-9.]+) (\d{1,50}):([^']{1,80})\(\d+\)' logged (in|out)(?: .*)?$`)
// ConsoleRecord is supplied by Run's stdout/stderr stream, not by the server
// execution log. The channel never accepts a file path or a raw log archive.
type ConsoleRecord struct {
@@ -21,6 +24,7 @@ type SemanticEvent struct {
Sequence uint64
Type string
PlayerID string
DisplayName string
OccurredAt time.Time
NetworkCorrelation string
}
@@ -62,6 +66,9 @@ func ParseConsoleRecords(serverID string, records []ConsoleRecord, correlationSe
return batch
}
func parseConsoleRecord(record ConsoleRecord, secret string) (SemanticEvent, bool) {
if event, ok := parseLoginLogRecord(record, secret); ok {
return event, true
}
fields := strings.Fields(record.Text)
if len(fields) < 3 || fields[0] != "SCUM" || (fields[1] != "LOGIN" && fields[1] != "LOGOUT") || !steamID64(fields[2]) {
return SemanticEvent{}, false
@@ -76,9 +83,26 @@ func parseConsoleRecord(record ConsoleRecord, secret string) (SemanticEvent, boo
}
return event, true
}
func parseLoginLogRecord(record ConsoleRecord, secret string) (SemanticEvent, bool) {
match := scumLoginLogLine.FindStringSubmatch(record.Text)
if match == nil || !steamID64(match[2]) {
return SemanticEvent{}, false
}
eventType := "scum.login"
if match[4] == "out" {
eventType = "scum.logout"
}
event := SemanticEvent{ServerID: record.ServerID, Sequence: record.Sequence, Type: eventType, PlayerID: match[2], DisplayName: match[3], OccurredAt: record.OccurredAt}
if secret != "" {
event.NetworkCorrelation = networkCorrelation(record.ServerID, match[1], secret)
}
return event, true
}
func networkCorrelation(serverID, value, secret string) string {
digest := sha256.Sum256([]byte(serverID + "\x00" + secret + "\x00" + value))
return hex.EncodeToString(digest[:16])
return hex.EncodeToString(digest[:])
}
func appendDiagnostic(existing []EventDiagnostic, diagnostic EventDiagnostic) []EventDiagnostic {
if len(existing) >= 32 {
@@ -17,4 +17,24 @@ func TestConsoleSemanticEventProducerParsesOnlyBoundedKnownOutput(t *testing.T)
if batch.Events[0].NetworkCorrelation == "10.0.0.1" {
t.Fatal("raw network value leaked")
}
if len(batch.Events[0].NetworkCorrelation) != 64 {
t.Fatalf("network correlation must be full sha256 hex, got %q", batch.Events[0].NetworkCorrelation)
}
}
func TestConsoleSemanticEventProducerParsesScumLoginLog(t *testing.T) {
observedAt := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
batch := ParseConsoleRecords("server-1", []ConsoleRecord{
{ServerID: "server-1", Stream: "stdout", Sequence: 1, OccurredAt: observedAt, Text: "2026.08.27-12.00.00: '10.0.0.2 76561198000000002:Ada(42)' logged in at: X=1 Y=2 Z=3"},
{ServerID: "server-1", Stream: "stdout", Sequence: 2, OccurredAt: observedAt.Add(time.Second), Text: "2026.08.27-12.00.01: '10.0.0.2 76561198000000002:Ada(42)' logged out"},
}, "fixture-secret")
if len(batch.Events) != 2 || batch.Events[0].Type != "scum.login" || batch.Events[1].Type != "scum.logout" {
t.Fatalf("login log events not parsed: %+v", batch)
}
if batch.Events[0].PlayerID != "76561198000000002" || batch.Events[0].DisplayName != "Ada" || len(batch.Events[0].NetworkCorrelation) != 64 {
t.Fatalf("login event fields are incomplete: %+v", batch.Events[0])
}
if batch.Events[0].NetworkCorrelation == "10.0.0.2" {
t.Fatal("raw login log IP leaked")
}
}
@@ -101,6 +101,8 @@ export type SCUMSurfaceData = {
nativeEventRounds: RecordMap[];
tasks: RecordMap[];
activityEvents: RecordMap[];
tradeGoods: RecordMap[];
tradeEvents: RecordMap[];
gifts: RecordMap[];
giftClaims: RecordMap[];
pendingGifts: RecordMap[];
@@ -111,11 +113,12 @@ export type SCUMSurfaceData = {
mapSettings: RecordMap[];
vehicles: RecordMap[];
flags: RecordMap[];
trajectories: RecordMap[];
};
export const emptySCUMSurfaceData: SCUMSurfaceData = {
players: [], squads: [], members: [], events: [], eventProduces: [], eventRuns: [], nativeEventRounds: [], tasks: [], activityEvents: [],
gifts: [], giftClaims: [], pendingGifts: [], giftDeliveries: [], timedGiftEvents: [], mapPoints: [], mapRegions: [], mapSettings: [], vehicles: [], flags: []
tradeGoods: [], tradeEvents: [], gifts: [], giftClaims: [], pendingGifts: [], giftDeliveries: [], timedGiftEvents: [], mapPoints: [], mapRegions: [], mapSettings: [], vehicles: [], flags: [], trajectories: []
};
export const scumCollections = {
@@ -128,6 +131,8 @@ export const scumCollections = {
nativeEventRounds: "scum_native_event_rounds",
tasks: "scum_tasks",
activityEvents: "scum_activity_events",
tradeGoods: "scum_trade_goods",
tradeEvents: "scum_trade_events",
gifts: "scum_gifts",
giftClaims: "scum_gift_claims",
pendingGifts: "scum_pending_gifts",
@@ -137,18 +142,19 @@ export const scumCollections = {
mapRegions: "scum_map_regions",
mapSettings: "scum_map_settings",
vehicles: "scum_vehicles",
flags: "scum_flags"
flags: "scum_flags",
trajectories: "scum_trajectories"
} as const;
type SurfaceKey = keyof SCUMSurfaceData;
type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows";
const pageCollections: Record<PageKey, SurfaceKey[]> = {
players: ["players", "members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries"],
players: ["players", "members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries", "trajectories", "vehicles"],
squads: ["squads", "members", "flags"],
"live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags"],
gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players"],
workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents"]
"live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags", "trajectories", "tradeGoods"],
gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players", "tradeGoods"],
workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents", "tradeGoods", "tradeEvents"]
};
export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise<SCUMSurfaceData> {
@@ -161,6 +167,10 @@ export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: st
const playersSnapshot = await actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "players", streamKey: "current", limit: 1 }).catch(() => undefined);
data.players = mergePlayerSnapshots(data.players, playersSnapshot);
}
if (keys.includes("vehicles") && actions.gameClient) {
const vehiclesSnapshot = await actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "vehicles", streamKey: "current", limit: 1 }).catch(() => undefined);
data.vehicles = mergeVehicleSnapshots(data.vehicles, vehiclesSnapshot);
}
return data;
}
@@ -184,6 +194,25 @@ export function mergePlayerSnapshots(players: RecordMap[], playersResponse: unkn
return merged;
}
export function mergeVehicleSnapshots(vehicles: RecordMap[], vehiclesResponse: unknown): RecordMap[] {
const vehicleSnapshot = latestSnapshotPayload(vehiclesResponse);
const merged = vehicles.map((vehicle) => ({ ...vehicle }));
const snapshotVehicles = Array.isArray(vehicleSnapshot?.vehicles) ? vehicleSnapshot.vehicles.filter(isRecord) : [];
if (!snapshotVehicles.length) return merged;
const byIdentity = vehicleIndex(merged);
for (const snapshotVehicle of snapshotVehicles) {
const match = findVehicle(byIdentity, snapshotVehicle);
const value = { ...(match ? merged[match.index] : {}), ...snapshotVehicle, vehicleObservedAt: textValue(vehicleSnapshot?.observedAt) };
if (match) merged[match.index] = value;
else {
const created = { ...value, vehicleId: firstText(snapshotVehicle, "vehicleId", "entityId", "id") };
merged.push(created);
addVehicleToIndex(byIdentity, created, merged.length - 1);
}
}
return merged;
}
export async function saveGiftDefinition(actions: SCUMWorkspaceActions, gift: RecordMap): Promise<unknown> {
const key = requiredKey(gift, "code", "礼包编号");
return requirePluginData(actions).transact(scumCollections.gifts, [{ operation: "put", key, value: gift }]);
@@ -342,6 +371,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 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 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[] {
const identities = new Set<string>();
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 AttributeDraft = { fieldKey: string; label: string; before: string; after: string };
const scumMapBackground = new URL("../assets/map/scum-map-overview.jpg", import.meta.url).href;
const scumMapSize = 256;
const rideDistanceThreshold = 50000;
const vehicleIconByClass: Record<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 = {
createElement: (...args: any[]) => any;
@@ -122,7 +140,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
if (react.useEffect) react.useEffect(() => {
if (playerPanel.kind === "closed") refresh();
if (playerPanel.kind !== "closed") return;
const interval = setInterval(refresh, 10000);
const interval = setInterval(refresh, 3000);
return () => clearInterval(interval);
}, [input.serverInstanceId, pageKey, input.workspaceActions, playerPanel.kind]);
@@ -236,7 +254,7 @@ function playerDrawer(e: ReactLike["createElement"], player: RecordMap, data: SC
e("div", { className: "panel-header" }, e("div", null, e("h2", null, title), e("span", { className: "provider-id" }, `${name} · Steam ${textField(player, "steamId", "providerId") || "未同步"}`)), e("button", { type: "button", className: "drawer-close", onClick: close }, "关闭")),
e("div", { className: "console-record-meta scum-player-overview" },
e("span", null, `Profile ${textField(player, "userProfileId", "profileId") || "未同步"}`),
e("span", null, `登录 IP ${textField(player, "lastLoginIp", "loginIp", "ipAddress", "lastIp") || "未同步"}`),
e("span", null, `网络相关 ${shortHash(textField(player, "networkCorrelation")) || "未同步"}`),
e("span", null, `Fame ${numField(player, "famePoints")}`),
e("span", null, `Cash ${numField(player, "normalBalance", "moneyBalance")} · Gold ${numField(player, "goldBalance")}`),
e("span", null, `上次登录 ${userDateField(player, "lastLoginTime", "lastLoginAt", "lastLoginObservedAt")}`)),
@@ -286,12 +304,12 @@ function playerItemsPanel(e: ReactLike["createElement"], player: RecordMap) {
function playerHistoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) {
const rows = playerRecords(data.activityEvents, player).filter((row) => ["login", "logout", "scum.login", "scum.logout"].includes(textField(row, "eventType", "type").toLowerCase()));
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录历史来自插件日志同步事件;网络信息按插件声明字段展示。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `history-${index}`), className: "console-row" }, e("span", null, textField(row, "eventType", "type") || "登录事件"), e("strong", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, textField(row, "loginIp", "ipAddress", "lastIp") || "IP 未同步"))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。")));
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录历史来自插件声明的 SCUM 登录日志投影;网络字段只显示不可逆相关性哈希。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `history-${index}`), className: "console-row" }, e("span", null, textField(row, "eventType", "type") || "登录事件"), e("strong", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, shortHash(textField(row, "networkCorrelation")) || textField(row, "reason") || "无网络字段"))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。")));
}
function playerTrajectoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) {
const rows = playerRecords(data.activityEvents, player).filter((row) => hasCoordinates(positionOf(row)));
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹只展示插件声明并已同步的位置事件,不从机器文件或 SCUM.db 外部猜测。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `trajectory-${index}`), className: "console-row" }, e("span", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, coords(positionOf(row))), e("strong", null, textField(row, "source") || "plugin log"))) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。")));
const rows = playerRecords(data.trajectories, player).filter((row) => layerOf(row) === "players" && hasCoordinates(positionOf(row))).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 120);
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹来自 Run 每 3 秒查询 SCUM.db 的采样投影;乘车状态按同一时刻附近载具保守标识。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => { const ride = nearbyVehicle(row, data.vehicles); return e("div", { key: idOf(row, `trajectory-${index}`), className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt", "createdAt")), e("strong", null, coords(positionOf(row))), e("strong", null, ride ? `疑似乘坐 ${pointTitle(ride)}` : textField(row, "source") || "run.sqlite")); }) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。")));
}
function playerRecords(rows: RecordMap[], player: RecordMap): RecordMap[] { const identities = playerIdentities(player); return rows.filter((row) => identities.includes(textField(row, "steamId", "playerId", "gamePlayerId", "userProfileId", "profileId"))); }
@@ -336,6 +354,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
return view.activityStatus === "all" || status === view.activityStatus;
});
const statuses = unique(data.events.map((event) => textField(runsByEvent.get(textField(event, "id", "eventId")), "status", "state") || textField(event, "status", "state")).filter(Boolean));
const tradeEvents = recentRows(data.tradeEvents, "observedAt", "occurredAt", "createdAt");
const saveEvent = () => runAction(view.setAction, "正在保存活动定义…", async () => {
const id = view.eventId.trim();
const name = view.eventName.trim();
@@ -362,7 +381,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
});
const activityHistory = data.activityEvents.filter((event) => Boolean(textField(event, "occurredAt", "createdAt")) && textField(event, "taskKind").toLowerCase() !== "active-task");
return e("div", { className: "console-record-list" },
statsStrip(e, [["活动定义", data.events.length], ["运行记录", data.eventRuns.length], ["原生赛事轮次", data.nativeEventRounds.length], ["任务", data.tasks.length]]),
statsStrip(e, [["活动定义", data.events.length], ["运行记录", data.eventRuns.length], ["物品列表", data.tradeGoods.length], ["交易记录", data.tradeEvents.length]]),
e("div", { className: "overview-two-col" },
e("details", { className: "console-module scum-editor", open: view.eventEditorOpen, onToggle: (event: InputEvent) => view.setEventEditorOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "新建或更新活动"), e("span", { className: "page-status" }, view.eventId || view.eventName ? "编辑中" : "点击展开")),
labeledField(e, "活动编号", e("input", { value: view.eventId, "aria-label": "活动编号", placeholder: "例如 event_supply_drop", onChange: (event: InputEvent) => view.setEventId(inputValue(event)) })),
@@ -398,7 +417,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
labeledField(e, "Z", e("input", { value: view.produceZ, "aria-label": "生成 Z", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceZ(inputValue(event)) })),
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveProduce }, "保存生成项")),
e("div", { className: "console-row-list" }, data.eventProduces.length ? data.eventProduces.map((produce, index) => e("div", { key: idOf(produce, `produce-${index}`), className: "console-row" },
e("span", null, `${textField(produce, "eventId", "event")} / ${textField(produce, "tradeGoodsId", "trade_goods_id")}`),
e("span", null, `${textField(produce, "eventId", "event")} / ${tradeGoodsLabel(data.tradeGoods, textField(produce, "tradeGoodsId", "trade_goods_id"))}`),
e("strong", null, `${numField(produce, "percent")}% × ${numField(produce, "value")}`),
e("strong", null, `R ${numField(produce, "r")} · ${coords(produce)}`),
e("button", { type: "button", className: "icon-command", onClick: () => { view.setProduceEditorOpen(true); view.setProduceEventId(textField(produce, "eventId", "event")); view.setProduceId(textField(produce, "id", "produceId")); view.setProduceTradeGoodsId(textField(produce, "tradeGoodsId", "trade_goods_id")); view.setProducePercent(numField(produce, "percent")); view.setProduceValue(numField(produce, "value")); view.setProduceRadius(numField(produce, "r")); view.setProduceX(numField(produce, "x")); view.setProduceY(numField(produce, "y")); view.setProduceZ(numField(produce, "z")); } }, "编辑"),
@@ -422,6 +441,10 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
tablePanel(e, "原生赛事轮次", data.nativeEventRounds, (event) => [textField(event, "eventId") || "event", textField(event, "state") || "unknown", `Kills ${numField(event, "enemyKills")}`, dateField(event, "startTime")]),
tablePanel(e, "Quest / Task", data.tasks, (task) => [textField(task, "taskKind") || "task", textField(task, "dataAssetPath") || "unknown", textField(task, "state") || "unknown", textField(task, "userProfileId") || "unknown"])
),
e("div", { className: "overview-two-col" },
itemCatalogPanel(e, data.tradeGoods),
tablePanel(e, "最近商人交易", tradeEvents, (event) => [tradeGoodsLabel(data.tradeGoods, textField(event, "itemCode", "code", "tradeGoodsId")), `${tradeActionLabel(event)} × ${numField(event, "quantity", "itemCount")}`, `玩家 ${shortHash(textField(event, "steamId", "playerId"))}`, `价格 ${numField(event, "price", "currencyDelta")}`, dateField(event, "observedAt", "occurredAt", "createdAt")])
),
tablePanel(e, "最近活动记录", [...data.eventRuns, ...activityHistory], (event) => [textField(event, "eventName", "type", "kind", "activityType") || "event", textField(event, "subjectName", "eventId", "subjectId", "subject") || "unknown", textField(event, "status", "result", "state") || "unknown", dateField(event, "startedAt", "occurredAt", "createdAt")])
);
}
@@ -454,7 +477,7 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu
return "礼包发放命令已进入执行队列。";
});
return e("div", { className: "console-record-list" },
statsStrip(e, [["礼包定义", data.gifts.length], ["领取/待领", data.giftClaims.length + data.pendingGifts.length], ["发放记录", data.giftDeliveries.length], ["原生定时记录", data.timedGiftEvents.length]]),
statsStrip(e, [["礼包定义", data.gifts.length], ["领取/待领", data.giftClaims.length + data.pendingGifts.length], ["发放记录", data.giftDeliveries.length], ["物品列表", data.tradeGoods.length]]),
e("div", { className: "console-row-actions", role: "tablist", "aria-label": "礼包视图" },
giftTabButton(e, view, "definitions", "礼包定义"), giftTabButton(e, view, "claims", "领取/待领"), giftTabButton(e, view, "deliveries", "发放记录"), giftTabButton(e, view, "timed", "游戏定时记录")
),
@@ -481,6 +504,7 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu
);
}) : e("p", { className: "page-status" }, "暂无礼包定义。"))
) : null,
view.giftTab === "definitions" ? itemCatalogPanel(e, data.tradeGoods) : null,
view.giftTab === "claims" ? e("div", { className: "overview-two-col" },
resettableGiftPanel(e, "领取记录", data.giftClaims, "重置领取", (claim) => runAction(view.setAction, "正在重置领取记录…", async () => { await resetGiftClaim(actions ?? {}, claim); view.refresh(); return "领取记录已重置。"; }), actions),
resettableGiftPanel(e, "待领礼包", data.pendingGifts, "重置待领", (pending) => runAction(view.setAction, "正在重置待领记录…", async () => { await resetPendingGift(actions ?? {}, pending); view.refresh(); return "待领状态已重置。"; }), actions)
@@ -503,6 +527,7 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu
function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
const actions = input.workspaceActions;
const points = collectMapPoints(data);
const vehicleCatalog = data.tradeGoods.filter((item) => isVehicleCatalogItem(item));
const settings = data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? data.mapSettings[0];
const bounds = resolveMapBounds(settings);
const customEnabled = view.mapCustomEnabled ?? Boolean(settings && boolField(settings, "customMapEnabled"));
@@ -513,8 +538,10 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
const search = view.mapSearch.trim().toLowerCase();
const visible = points.filter((point) => view.mapLayers[layerOf(point)] && matchesText(point, search, "name", "label", "subjectId", "subjectType", "layer"));
const selected = visible.find((point, index) => idOf(point, `point-${index}`) === view.selectedMapPoint) ?? visible[0];
const trails = visibleTrajectoryPoints(data.trajectories, view.mapLayers, search).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 180).reverse();
const selectedTrails = selected ? trajectoryRecordsForPoint(data.trajectories, selected).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 8) : [];
return e("div", { className: "console-record-list" },
statsStrip(e, [["地图点", points.length], ["用户", data.players.length], ["载具", data.vehicles.length], ["旗帜/区域", data.flags.length + data.mapRegions.length]]),
statsStrip(e, [["地图点", points.length], ["用户", data.players.length], ["载具", data.vehicles.length], ["载具目录", vehicleCatalog.length]]),
e("div", { className: "resource-filter-bar scum-filter-bar" },
labeledField(e, "筛选地图点", e("input", { value: view.mapSearch, "aria-label": "筛选地图点", placeholder: "名称 / 类型 / ID", onChange: (event: InputEvent) => view.setMapSearch(inputValue(event)) })),
(["players", "vehicles", "flags", "regions", "other"] as MapLayer[]).map((layer) => e("label", { key: layer, className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: view.mapLayers[layer], onChange: (event: InputEvent) => view.setMapLayers((previous) => ({ ...previous, [layer]: Boolean(event.target?.checked) })) }), layerLabel(layer)))
@@ -529,8 +556,8 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在保存地图范围…", async () => { await saveMapSettings(actions ?? {}, { customMapEnabled: customEnabled, centerX: numberInput(centerX, 0), centerY: numberInput(centerY, 0), widthKm: numberInput(widthKm, 15.24), heightKm: numberInput(heightKm, 15.24) }); view.refresh(); return "地图范围已保存。"; }) }, "保存地图范围"))
),
e("div", { className: "overview-two-col" },
e("div", { className: "map-projection-board", "aria-label": "SCUM 地图图层", style: { backgroundImage: `url(${scumMapBackground})` } }, visible.map((point, index) => e("button", { key: idOf(point, `point-${index}`), type: "button", className: "map-projection-dot", title: `${pointTitle(point)} ${coords(point)}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => view.setSelectedMapPoint(idOf(point, `point-${index}`)) }, ""))),
e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "地图点详情"), e("span", { className: "page-status" }, `${visible.length} 个可见点`)), selected ? e("div", { className: "console-record" }, e("strong", null, pointTitle(selected)), e("span", { className: "status-pill status-active" }, layerLabel(layerOf(selected))), e("span", { className: "provider-id" }, coords(selected)), e("div", { className: "console-record-meta" }, e("span", null, `ID ${textField(selected, "subjectId", "id", "_recordKey") || "unknown"}`), e("span", null, `来源 ${textField(selected, "source") || "plugin collection"}`), e("span", null, freshness(selected)))) : e("p", { className: "page-status" }, "当前图层和筛选条件下没有真实地图点。"))
e("div", { className: "map-projection-board", "aria-label": "SCUM 地图图层", style: { backgroundImage: `url(${scumMapBackground})` } }, mapGridOverlay(e), trails.map((point, index) => e("span", { key: `trail-${index}-${idOf(point, "sample")}`, className: `map-trajectory-dot map-layer-${layerOf(point)}`, title: `${pointTitle(point)} ${dateField(point, "sampledAt", "observedAt")}`, style: mapPointStyle(point, bounds) })), visible.map((point, index) => { const ride = layerOf(point) === "players" ? nearbyVehicle(point, data.vehicles) : undefined; return e("button", { key: idOf(point, `point-${index}`), type: "button", className: `map-projection-dot map-layer-${layerOf(point)}${ride ? " map-projection-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 疑似乘坐 ${pointTitle(ride)}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => view.setSelectedMapPoint(idOf(point, `point-${index}`)) }, vehicleIconFor(point) ? e("img", { src: vehicleIconFor(point), alt: "" }) : ""); })),
e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "地图点详情"), e("span", { className: "page-status" }, `${visible.length} 个可见点`)), selected ? e("div", { className: "console-record" }, e("strong", null, pointTitle(selected)), e("span", { className: "status-pill status-active" }, layerLabel(layerOf(selected))), e("span", { className: "provider-id" }, coords(selected)), e("div", { className: "console-record-meta" }, e("span", null, `ID ${textField(selected, "subjectId", "id", "_recordKey") || "unknown"}`), e("span", null, `来源 ${textField(selected, "source") || "plugin collection"}`), e("span", null, freshness(selected))), layerOf(selected) === "vehicles" ? e("div", { className: "console-record-meta" }, e("span", null, `类型 ${textField(selected, "className", "vehicleClass", "vehicleType") || "unknown"}`), e("span", null, `状态 ${textField(selected, "status", "state", "isFunctional") || "unknown"}`), e("span", null, `访问 ${dateField(selected, "lastAccessTime", "vehicleObservedAt", "sampledAt")}`)) : null, 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" }, "当前图层和筛选条件下没有真实地图点。"))
)
);
}
@@ -541,11 +568,24 @@ function giftTabButton(e: ReactLike["createElement"], view: ViewState, tab: Gift
function statsStrip(e: ReactLike["createElement"], items: Array<[string, number]>) { return e("div", { className: "console-stat-strip" }, items.map(([label, value]) => e("span", { key: label, className: "server-card-stat" }, e("span", null, label), e("strong", null, String(value))))); }
function tablePanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], render: (row: RecordMap) => unknown[]) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length}`)), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `${title}-${index}`), className: "console-row" }, render(row).map((part, partIndex) => partIndex === 0 ? e("span", { key: partIndex }, String(part ?? "unknown")) : e("strong", { key: partIndex }, String(part ?? "unknown"))))) : e("p", { className: "page-status" }, "暂无真实记录。"))); }
function resettableGiftPanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], actionLabel: string, onReset: (row: RecordMap) => void, actions: SCUMWorkspaceActions | undefined) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length}`)), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `${title}-${index}`), className: "console-row" }, e("span", null, textField(row, "playerName", "playerId", "displayName", "userProfileId") || "unknown"), e("strong", null, textField(row, "giftName", "giftCode", "giftType") || "unknown"), e("strong", null, textField(row, "status") || "unknown"), e("strong", null, dateField(row, "claimedAt", "receivedAt", "createdAt")), e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => onReset(row) }, actionLabel))) : e("p", { className: "page-status" }, "暂无真实记录。"))); }
function itemCatalogPanel(e: ReactLike["createElement"], rows: RecordMap[]) { return tablePanel(e, "物品列表", recentRows(rows, "lastSeenAt", "updatedAt", "createdAt"), (item) => [tradeGoodsName(item), tradeGoodsTypeLabel(item), textField(item, "code", "itemCode", "className") || "unknown", dateField(item, "lastSeenAt", "updatedAt", "createdAt")]); }
function tradeGoodsLabel(rows: RecordMap[], code: string): string { const item = tradeGoodsIndex(rows).get(code) ?? tradeGoodsIndex(rows).get(code.replace(/^#spawnvehicle\s+/i, "")); return item ? `${tradeGoodsName(item)} (${code})` : code || "unknown"; }
function tradeGoodsName(item: RecordMap): string { return textField(item, "nameCn", "name_cn", "name", "className") || textField(item, "code", "itemCode") || "未命名物品"; }
function tradeGoodsTypeLabel(item: RecordMap): string { return textField(item, "typeName", "type_name") || (textField(item, "type") === "21" || isVehicleCatalogItem(item) ? "其他载具" : "未知类型"); }
function tradeActionLabel(event: RecordMap): string { const value = textField(event, "tradeVerb", "tradeKind", "action").toLowerCase(); return value === "purchased" || value === "purchase" ? "买入" : value === "sold" || value === "sale" ? "卖出" : value || "交易"; }
function tradeGoodsIndex(rows: RecordMap[]): Map<string, RecordMap> { const result = new Map<string, RecordMap>(); for (const row of rows) for (const key of [textField(row, "code"), textField(row, "itemCode"), textField(row, "className")].filter(Boolean)) result.set(key, row); return result; }
function recentRows(rows: RecordMap[], ...keys: string[]): RecordMap[] { return [...rows].sort((left, right) => rowTime(right, keys) - rowTime(left, keys)).slice(0, 24); }
function rowTime(row: RecordMap, keys: string[]): number { for (const key of keys) { const stamp = Date.parse(textField(row, key)); if (!Number.isNaN(stamp)) return stamp; } return 0; }
function isVehicleCatalogItem(item: RecordMap): boolean { const type = textField(item, "catalogType", "kind", "typeName", "type_name", "type").toLowerCase(); const code = textField(item, "code", "spawnCommand").toLowerCase(); return type.includes("vehicle") || type.includes("载具") || textField(item, "type") === "21" || code.startsWith("#spawnvehicle"); }
function vehicleCatalogIndex(rows: RecordMap[]): Map<string, RecordMap> { const result = new Map<string, RecordMap>(); for (const row of rows) if (isVehicleCatalogItem(row)) for (const key of vehicleCatalogKeys(row)) result.set(key, row); return result; }
function vehicleCatalogKeys(row: RecordMap): string[] { return ["className", "vehicleClass", "entityClass", "vehicleType", "name", "code", "spawnCommand"].map((key) => vehicleClassKey(textField(row, key))).filter(Boolean); }
function enrichVehicleFromCatalog(vehicle: RecordMap, catalog: Map<string, RecordMap>): RecordMap { const found = vehicleCatalogKeys(vehicle).map((key) => catalog.get(key)).find(Boolean); return found ? { ...found, ...vehicle, imagePath: textField(vehicle, "imagePath", "image_path") || textField(found, "imagePath", "image_path"), catalogCode: textField(found, "code"), spawnCommand: textField(found, "spawnCommand", "code") } : vehicle; }
export function collectMapPoints(data: SCUMSurfaceData): RecordMap[] {
const direct = data.mapPoints.map((point) => ({ ...point, layer: textField(point, "layer", "subjectType", "type") || "other" }));
const players = data.players.flatMap((player) => withPosition(player, "players", textField(player, "displayName"), textField(player, "steamId", "gamePlayerId", "id")));
const vehicles = data.vehicles.flatMap((vehicle) => withPosition(vehicle, "vehicles", textField(vehicle, "label", "name"), textField(vehicle, "vehicleId", "id")));
const catalog = vehicleCatalogIndex(data.tradeGoods);
const vehicles = data.vehicles.flatMap((vehicle) => { const enriched = enrichVehicleFromCatalog(vehicle, catalog); return withPosition(enriched, "vehicles", textField(enriched, "label", "name", "className"), textField(enriched, "vehicleId", "id")); });
const flags = data.flags.flatMap((flag) => withPosition(flag, "flags", textField(flag, "name"), textField(flag, "flagId", "id")));
const regions = data.mapRegions.flatMap((region) => withPosition(region, "regions", textField(region, "name"), textField(region, "id", "regionId")));
const uniquePoints = new Map<string, RecordMap>();
@@ -557,7 +597,7 @@ export function collectMapPoints(data: SCUMSurfaceData): RecordMap[] {
return [...uniquePoints.values()];
}
function withPosition(row: RecordMap, layer: MapLayer, name: string, subjectId: string): RecordMap[] { const position = positionOf(row); return hasCoordinates(position) ? [{ ...position, layer, name, subjectId, _recordKey: `${layer}:${subjectId}`, source: textField(row, "source") }] : []; }
function withPosition(row: RecordMap, layer: MapLayer, name: string, subjectId: string): RecordMap[] { const position = positionOf(row); return hasCoordinates(position) ? [{ ...row, ...position, layer, name, subjectId, _recordKey: `${layer}:${subjectId}`, source: textField(row, "source") }] : []; }
function positionOf(row: RecordMap | undefined): RecordMap | undefined { const nested = field(row, "position", "location"); return isRecord(nested) ? nested : row; }
function hasCoordinates(row: RecordMap | undefined): row is RecordMap { return Boolean(row) && Number.isFinite(Number(field(row, "x", "locationX"))) && Number.isFinite(Number(field(row, "y", "locationY"))); }
function layerOf(point: RecordMap): MapLayer { const value = textField(point, "layer", "subjectType", "type").toLowerCase(); if (value.includes("player") || value.includes("user")) return "players"; if (value.includes("vehicle")) return "vehicles"; if (value.includes("flag")) return "flags"; if (value.includes("region") || value.includes("zone") || value === "base") return "regions"; return "other"; }
@@ -567,11 +607,29 @@ function mapPointIdentity(point: RecordMap): string { const subject = textField(
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 minX = Number(field(bounds, "worldMinX")); const minY = Number(field(bounds, "worldMinY")); const maxX = Number(field(bounds, "worldMaxX")); const maxY = Number(field(bounds, "worldMaxY"));
const left = Number.isFinite(minX) && Number.isFinite(maxX) && maxX > minX ? 100 - (x - minX) / (maxX - minX) * 100 : 50;
const top = Number.isFinite(minY) && Number.isFinite(maxY) && maxY > minY ? 100 - (y - minY) / (maxY - minY) * 100 : 50;
const mapX = Number.isFinite(minX) && Number.isFinite(maxX) && maxX > minX ? scumMapSize - (x - minX) * scumMapSize / (maxX - minX) : scumMapSize / 2;
const mapY = Number.isFinite(minY) && Number.isFinite(maxY) && maxY > minY ? scumMapSize - (y - minY) * scumMapSize / (maxY - minY) : scumMapSize / 2;
const left = mapX / scumMapSize * 100;
const top = mapY / scumMapSize * 100;
return { left: `${Math.max(1, Math.min(99, left))}%`, top: `${Math.max(1, Math.min(99, top))}%` };
}
function mapGridOverlay(e: ReactLike["createElement"]) {
const rows = ["D", "C", "B", "A", "Z"]; const cols = ["4", "3", "2", "1", "0"]; const breaks = [20, 40, 60, 80];
return e("div", { className: "map-grid-overlay", "aria-hidden": "true" }, breaks.map((value) => e("span", { key: `v-${value}`, className: "map-grid-line map-grid-line-v", style: { left: `${value}%` } })), breaks.map((value) => e("span", { key: `h-${value}`, className: "map-grid-line map-grid-line-h", style: { top: `${value}%` } })), cols.map((label, index) => e("span", { key: `c-${label}`, className: "map-grid-label map-grid-col-label", style: { left: `${(index + 0.5) * 20}%` } }, label)), rows.map((label, index) => e("span", { key: `r-${label}`, className: "map-grid-label map-grid-row-label", style: { top: `${(index + 0.5) * 20}%` } }, label)));
}
function visibleTrajectoryPoints(rows: RecordMap[], layers: Record<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 { const explicit = textField(point, "imagePath", "image_path"); if (explicit) return explicit.startsWith("/") ? explicit : `/${explicit}`; return vehicleIconByClass[normalizedVehicleClass(textField(point, "className", "vehicleClass", "entityClass", "vehicleType"))] ?? ""; }
function vehicleClassKey(value: string): string { return normalizedVehicleClass(value.replace(/^#spawnvehicle\s+/i, "")); }
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 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 ?? ""; }
@@ -310,7 +310,66 @@
"sqlRef": "sql/scum-db-v57/vehicles.sql",
"pollIntervalSeconds": 3,
"maxRows": 500,
"timeoutSeconds": 15
"timeoutSeconds": 15,
"projections": [
{
"collection": "scum_vehicles",
"rowPath": "rows",
"upsertKeys": [
"vehicleId"
],
"fixedValues": {
"source": "run.sqlite.scum.vehicles"
},
"observedAtField": "sampledAt"
},
{
"collection": "scum_trajectories",
"rowPath": "rows",
"upsertKeys": [
"subjectType",
"subjectId",
"sampledAt"
],
"fieldMappings": {
"subjectId": "vehicleId",
"vehicleId": "vehicleId",
"entityId": "entityId",
"className": "className",
"label": "label",
"x": "x",
"y": "y",
"z": "z",
"lastAccessTime": "lastAccessTime"
},
"fixedValues": {
"subjectType": "vehicle",
"source": "run.sqlite.scum.vehicles"
},
"observedAtField": "sampledAt"
},
{
"collection": "scum_trade_goods",
"rowPath": "rows",
"upsertKeys": [
"code"
],
"fieldMappings": {
"className": "className"
},
"fixedValues": {
"code": "#spawnvehicle {{className}}",
"spawnCommand": "#spawnvehicle {{className}}",
"catalogType": "vehicle",
"type": "21",
"typeName": "其他载具",
"imagePath": "/original/{{className}}.webp",
"source": "run.sqlite.scum.vehicles"
},
"observedAtField": "lastSeenAt",
"mergeExisting": true
}
]
},
{
"key": "scum.flags",
@@ -338,7 +397,69 @@
"sqlRef": "sql/scum-db-v57/map-points.sql",
"pollIntervalSeconds": 3,
"maxRows": 500,
"timeoutSeconds": 15
"timeoutSeconds": 15,
"projections": [
{
"collection": "scum_users",
"rowPath": "rows",
"matchField": "subjectType",
"matchValue": "player",
"upsertKeys": [
"steamId"
],
"fieldMappings": {
"steamId": "subjectId",
"userProfileId": "userProfileId",
"gamePlayerId": "gamePlayerId",
"x": "x",
"y": "y",
"z": "z",
"lastPositionObservedAt": "observedAt"
},
"fixedValues": {
"source": "run.sqlite.scum.positions"
},
"observedAtField": "positionSampledAt"
},
{
"collection": "scum_map_points",
"rowPath": "rows",
"upsertKeys": [
"subjectType",
"subjectId"
],
"fixedValues": {
"source": "run.sqlite.scum.positions"
},
"observedAtField": "sampledAt"
},
{
"collection": "scum_trajectories",
"rowPath": "rows",
"matchField": "subjectType",
"matchValue": "player",
"upsertKeys": [
"subjectType",
"subjectId",
"sampledAt"
],
"fieldMappings": {
"subjectType": "subjectType",
"subjectId": "subjectId",
"steamId": "subjectId",
"userProfileId": "userProfileId",
"gamePlayerId": "gamePlayerId",
"x": "x",
"y": "y",
"z": "z",
"observedAt": "observedAt"
},
"fixedValues": {
"source": "run.sqlite.scum.positions"
},
"observedAtField": "sampledAt"
}
]
},
{
"key": "scum.tasks",
@@ -384,6 +505,75 @@
}
],
"logProjections": [
{
"key": "scum.trade.catalog",
"streamKeys": [
"scum.trade"
],
"steps": [
{
"pattern": "^\\d{4}\\.\\d{2}\\.\\d{2}-\\d{2}\\.\\d{2}\\.\\d{2}: \\[Trade\\] Tradeable \\((?P<itemCode>[A-Za-z0-9_.-]{1,128}) \\(x(?P<quantity>\\d{1,9})\\)\\) (?P<tradeVerb>purchased|sold) by .*?\\((?P<steamId>\\d{1,50})\\) for (?P<price>-?\\d{1,12})$"
}
],
"correlationFields": [
"itemCode"
],
"maxInterveningLines": 0,
"target": {
"collection": "scum_trade_goods",
"upsertKeys": [
"code"
],
"captureMappings": {
"code": "itemCode"
},
"fixedValues": {
"catalogType": "item",
"source": "scum.trade"
},
"observedAtField": "lastSeenAt"
}
},
{
"key": "scum.trade.events",
"streamKeys": [
"scum.trade"
],
"steps": [
{
"pattern": "^\\d{4}\\.\\d{2}\\.\\d{2}-\\d{2}\\.\\d{2}\\.\\d{2}: \\[Trade\\] Tradeable \\((?P<itemCode>[A-Za-z0-9_.-]{1,128}) \\(x(?P<quantity>\\d{1,9})\\)\\) (?P<tradeVerb>purchased|sold) by .*?\\((?P<steamId>\\d{1,50})\\) for (?P<price>-?\\d{1,12})$"
}
],
"correlationFields": [
"steamId",
"itemCode",
"tradeVerb"
],
"maxInterveningLines": 0,
"target": {
"collection": "scum_trade_events",
"upsertKeys": [
"steamId",
"itemCode",
"tradeVerb",
"quantity",
"price",
"observedAt"
],
"captureMappings": {
"steamId": "steamId",
"itemCode": "itemCode",
"tradeVerb": "tradeVerb",
"quantity": "quantity",
"price": "price"
},
"fixedValues": {
"eventType": "trade",
"source": "scum.trade"
},
"observedAtField": "observedAt"
}
},
{
"key": "scum.battleye.login",
"streamKeys": [
@@ -437,6 +627,237 @@
"observedAtField": "observedAt"
}
}
},
{
"key": "scum.login-log.login",
"streamKeys": [
"scum.login"
],
"steps": [
{
"pattern": "^\\d{4}\\.\\d{2}\\.\\d{2}-\\d{2}\\.\\d{2}\\.\\d{2}: '(?P<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": [
@@ -797,6 +1218,62 @@
"path": "assets/map/scum-map-overview.jpg",
"mode": 384
},
{
"path": "assets/vehicles/vehicle-BPC_Barba.webp",
"mode": 384
},
{
"path": "assets/vehicles/vehicle-BPC_CityBike.webp",
"mode": 384
},
{
"path": "assets/vehicles/vehicle-BPC_Cruiser.webp",
"mode": 384
},
{
"path": "assets/vehicles/vehicle-BPC_Dirtbike.webp",
"mode": 384
},
{
"path": "assets/vehicles/vehicle-BPC_Kinglet_Duster.webp",
"mode": 384
},
{
"path": "assets/vehicles/vehicle-BPC_Kinglet_Mariner.webp",
"mode": 384
},
{
"path": "assets/vehicles/vehicle-BPC_Laika.webp",
"mode": 384
},
{
"path": "assets/vehicles/vehicle-BPC_MountainBike.webp",
"mode": 384
},
{
"path": "assets/vehicles/vehicle-BPC_RIS.webp",
"mode": 384
},
{
"path": "assets/vehicles/vehicle-BPC_Rager.webp",
"mode": 384
},
{
"path": "assets/vehicles/vehicle-BPC_Tractor.webp",
"mode": 384
},
{
"path": "assets/vehicles/vehicle-BPC_WolfsWagen.webp",
"mode": 384
},
{
"path": "assets/vehicles/vehicle-BP_WheelBarrow_Improvised.webp",
"mode": 384
},
{
"path": "assets/vehicles/vehicle-BP_WheelBarrow_Metal.webp",
"mode": 384
},
{
"path": "sql/scum-db-v57/users.sql",
"mode": 384
@@ -5,10 +5,15 @@
"required": ["occurredAt", "playerId", "tradeKind", "itemCount", "currencyDelta", "suspicious"],
"properties": {
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
"source": { "type": "string", "enum": ["companion", "log-projection", "scum.trade"] },
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"counterpartyPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"itemCode": { "type": "string", "pattern": "^[A-Za-z0-9_.-]{1,128}$", "minLength": 1, "maxLength": 128 },
"tradeVerb": { "type": "string", "enum": ["purchased", "sold"] },
"tradeKind": { "type": "string", "enum": ["purchase", "sale", "transfer", "unknown"] },
"quantity": { "type": "integer", "minimum": 0, "maximum": 1000000000 },
"itemCount": { "type": "integer", "minimum": 0, "maximum": 1000 },
"price": { "type": "integer", "minimum": -1000000000, "maximum": 1000000000 },
"currencyDelta": { "type": "integer", "minimum": -1000000000, "maximum": 1000000000 },
"suspicious": { "type": "boolean" }
}
@@ -259,6 +259,11 @@
"items": { "$ref": "#/$defs/gameClientBridgeLogProjection" },
"maxItems": 128
},
"lifecycleProjections": {
"type": "array",
"items": { "$ref": "#/$defs/gameClientBridgeLifecycleProjection" },
"maxItems": 64
},
"dataPacks": {
"type": "array",
"items": { "$ref": "#/$defs/gameClientBridgeDataPack" },
@@ -337,7 +342,24 @@
"sqlRef": { "$ref": "#/$defs/relativeSqlRef" },
"maxRows": { "type": "integer", "minimum": 1, "maximum": 500 },
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 },
"pollIntervalSeconds": { "type": "integer", "minimum": 0, "maximum": 86400 }
"pollIntervalSeconds": { "type": "integer", "minimum": 0, "maximum": 86400 },
"projections": { "type": "array", "items": { "$ref": "#/$defs/gameClientBridgeQueryProjection" }, "uniqueItems": true, "maxItems": 4 }
}
},
"gameClientBridgeQueryProjection": {
"type": "object",
"required": ["collection", "rowPath", "upsertKeys"],
"additionalProperties": false,
"properties": {
"collection": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,119}$" },
"rowPath": { "const": "rows" },
"matchField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" },
"matchValue": { "type": "string", "minLength": 1, "maxLength": 120 },
"upsertKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 8 },
"fieldMappings": { "type": "object", "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" } },
"fixedValues": { "type": "object", "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "maxLength": 4096 } },
"observedAtField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" },
"mergeExisting": { "type": "boolean" }
}
},
"gameClientBridgeLogProjection": {
@@ -370,6 +392,7 @@
"collection": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,119}$" },
"upsertKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 8 },
"captureMappings": { "type": "object", "minProperties": 1, "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,79}$" } },
"hashMappings": { "type": "object", "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,79}$" } },
"fixedValues": { "type": "object", "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "maxLength": 4096 } },
"observedAtField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }
}
@@ -384,6 +407,42 @@
"activityTarget": { "$ref": "#/$defs/gameClientBridgeLogProjectionTarget" }
}
},
"gameClientBridgeLifecycleProjection": {
"type": "object",
"required": ["key", "capabilities", "target"],
"additionalProperties": false,
"properties": {
"key": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" },
"capabilities": { "type": "array", "items": { "$ref": "#/$defs/runCapability" }, "uniqueItems": true, "minItems": 1, "maxItems": 16 },
"processStates": { "type": "array", "items": { "enum": ["running", "stopped", "not-started", "exited"] }, "uniqueItems": true, "maxItems": 8 },
"target": { "$ref": "#/$defs/gameClientBridgeBulkProjectionTarget" }
}
},
"gameClientBridgeBulkProjectionTarget": {
"type": "object",
"required": ["collection", "matchField", "matchValue", "fixedValues"],
"additionalProperties": false,
"properties": {
"collection": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,119}$" },
"matchField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" },
"matchValue": { "type": "string", "minLength": 1, "maxLength": 120 },
"fixedValues": { "type": "object", "minProperties": 1, "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "maxLength": 4096 } },
"observedAtField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" },
"activityTarget": { "$ref": "#/$defs/gameClientBridgeBulkActivityTarget" }
}
},
"gameClientBridgeBulkActivityTarget": {
"type": "object",
"required": ["collection", "upsertKeys", "rowMappings"],
"additionalProperties": false,
"properties": {
"collection": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,119}$" },
"upsertKeys": { "type": "array", "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "uniqueItems": true, "minItems": 1, "maxItems": 8 },
"rowMappings": { "type": "object", "minProperties": 1, "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" } },
"fixedValues": { "type": "object", "maxProperties": 64, "propertyNames": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "additionalProperties": { "type": "string", "maxLength": 4096 } },
"observedAtField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }
}
},
"gameClientBridgeDataPack": {
"type": "object",
"required": ["key", "databaseUserVersion", "logParserRefs", "configMapRefs"],
+13
View File
@@ -251,6 +251,19 @@ export interface GameClientBridgeQueryTemplateDeclaration {
maxRows: number;
timeoutSeconds: number;
pollIntervalSeconds?: number;
projections?: GameClientBridgeQueryProjectionDeclaration[];
}
export interface GameClientBridgeQueryProjectionDeclaration {
collection: string;
rowPath: "rows";
matchField?: string;
matchValue?: string;
upsertKeys: string[];
fieldMappings?: Record<string, string>;
fixedValues?: Record<string, string>;
observedAtField?: string;
mergeExisting?: boolean;
}
export interface GameClientBridgeLogProjectionStepDeclaration {
+9 -1
View File
@@ -456,6 +456,8 @@ describe("plugin manifest validation", () => {
maxPayloadBytes: number;
}>;
snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>;
queryTemplates: Array<{ key: string; projections?: Array<{ collection?: string; fixedValues?: Record<string, string>; mergeExisting?: boolean }> }>;
logProjections?: Array<{ key: string; streamKeys?: string[]; target?: { collection?: string; upsertKeys?: string[]; captureMappings?: Record<string, string> } }>;
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] }>;
};
pages: Array<{ key: string; permissions?: string[] }>;
@@ -496,6 +498,12 @@ describe("plugin manifest validation", () => {
"maintenance.prepare"
]));
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]));
expect(manifest.gameClientBridge.queryTemplates.find((template) => template.key === "scum.vehicles")?.projections).toEqual(expect.arrayContaining([
expect.objectContaining({ collection: "scum_trade_goods", mergeExisting: true, fixedValues: expect.objectContaining({ catalogType: "vehicle", type: "21", typeName: "其他载具" }) })
]));
expect(manifest.gameClientBridge.logProjections?.map((projection) => projection.key)).toEqual(expect.arrayContaining(["scum.trade.catalog", "scum.trade.events"]));
expect(manifest.gameClientBridge.logProjections?.find((projection) => projection.key === "scum.trade.catalog")).toMatchObject({ streamKeys: ["scum.trade"], target: { collection: "scum_trade_goods", upsertKeys: ["code"], captureMappings: { code: "itemCode" } } });
expect(manifest.gameClientBridge.logProjections?.find((projection) => projection.key === "scum.trade.events")?.target?.collection).toBe("scum_trade_events");
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).not.toContain("files-config");
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows")?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.vehicles", "scum.flags", "scum.positions"]));
@@ -507,7 +515,7 @@ describe("plugin manifest validation", () => {
expect(manifest.fileWorkspace?.files.map((file) => file.key)).toEqual(expect.arrayContaining(["scum-server-settings", "scum-admin-users", "scum-chat-log", "scum-performance-log"]));
expect(manifest.fileWorkspace?.configFields.map((field) => field.key)).toEqual(expect.arrayContaining(["server-name", "game-port", "query-port", "max-players", "welcome-message"]));
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command");
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-client-events"]));
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-trade-events", "scum-client-events"]));
});
it("declares bounded and permissioned SCUM bridge commands", () => {
+21 -6
View File
@@ -24,6 +24,11 @@ const surfaceData: SCUMSurfaceData = {
nativeEventRounds: [{ eventRecordId: "native-1", eventId: "native-event", state: "active", startTime: "2026-08-10T00:00:00Z", enemyKills: 2 }],
tasks: [{ taskRecordId: "task-1", taskKind: "active-task", state: "active", userProfileId: "profile-1" }],
activityEvents: [{ id: "activity-1", type: "reward", subjectName: "Mira", status: "delivered", occurredAt: "2026-08-10T00:02:00Z" }],
tradeGoods: [
{ code: "goods-1", name: "Cargo Drop", catalogType: "item", lastSeenAt: "2026-08-10T00:01:00Z" },
{ code: "#spawnvehicle BPC_Laika_C", className: "BPC_Laika_C", catalogType: "vehicle", type: "21", typeName: "其他载具", imagePath: "/original/BPC_Laika_C.webp", lastSeenAt: "2026-08-10T00:00:03Z" }
],
tradeEvents: [{ steamId: "76561198000000001", itemCode: "goods-1", tradeVerb: "purchased", quantity: "2", price: "120", observedAt: "2026-08-10T00:01:00Z" }],
gifts: [{ code: "starter-pack", name: "Starter Pack", class: 5, audience: "all", number: 1, achievement: 2, achievementNumber: 10, status: "active", items: [{ catalogCode: "BP_Cash_01", quantity: 2 }], commands: [{ command: "#announce Starter pack" }] }],
giftClaims: [{ id: "claim-1", playerId: "steam-1", giftCode: "starter-pack", status: "claimed", claimedAt: "2026-08-10T00:03:00Z" }],
pendingGifts: [{ id: "pending-1", playerId: "steam-1", giftCode: "starter-pack", status: "pending", createdAt: "2026-08-10T00:03:30Z" }],
@@ -32,7 +37,11 @@ const surfaceData: SCUMSurfaceData = {
mapPoints: [{ id: "poi-1", name: "Airfield", layer: "other", x: 800, y: 900, z: 10, source: "plugin-map" }],
mapRegions: [{ id: "region-1", name: "Safe Zone", x: 500, y: 600, z: 0, source: "server-config" }],
mapSettings: [],
vehicles: [{ vehicleId: "veh-1", label: "Laika", position: { x: 400, y: 200, z: 0 }, freshness: { status: "fresh" } }],
vehicles: [{ vehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", 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" } }]
};
@@ -80,7 +89,7 @@ describe("SCUM plugin feature module", () => {
it("loads page data only through scoped plugin collections", async () => {
const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
const data = await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "gifts");
expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.gifts, scumCollections.giftClaims, scumCollections.pendingGifts, scumCollections.giftDeliveries, scumCollections.timedGiftEvents, scumCollections.players]);
expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.gifts, scumCollections.giftClaims, scumCollections.pendingGifts, scumCollections.giftDeliveries, scumCollections.timedGiftEvents, scumCollections.players, scumCollections.tradeGoods]);
expect(data.gifts[0]).toMatchObject({ collection: scumCollections.gifts, _recordKey: `${scumCollections.gifts}-1` });
});
@@ -89,7 +98,7 @@ describe("SCUM plugin feature module", () => {
const gameClient = gameClientActions();
gameClient.snapshots.mockResolvedValue({ items: [{ sequence: 2, observedAt: "2026-08-10T00:00:00Z", payload: { players: [{ playerId: "steam-1", playerName: "Mira", status: "online", pingMs: 32 }] } }] });
const data = await loadSCUMSurface({ pluginData, gameClient }, "players");
expect(gameClient.snapshots.mock.calls.map(([query]) => query?.type)).toEqual(["players"]);
expect(gameClient.snapshots.mock.calls.map(([query]) => query?.type)).toEqual(["players", "vehicles"]);
expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", status: "online", online: true, pingMs: 32, onlineObservedAt: "2026-08-10T00:00:00Z" });
const sameName = mergePlayerSnapshots([{ steamId: "steam-2", displayName: "Noah", online: false }], { items: [{ observedAt: "2026-08-10T00:02:00Z", payload: { players: [{ playerId: "steam-3", playerName: "Noah", status: "online" }] } }] });
expect(sameName).toHaveLength(2);
@@ -100,9 +109,9 @@ describe("SCUM plugin feature module", () => {
it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => {
const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "workflows");
expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents]);
expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents, scumCollections.tradeGoods, scumCollections.tradeEvents]);
await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "activity");
expect(list.mock.calls.slice(-6).map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents]);
expect(list.mock.calls.slice(-8).map(([collection]) => collection)).toEqual([scumCollections.events, scumCollections.eventProduces, scumCollections.eventRuns, scumCollections.nativeEventRounds, scumCollections.tasks, scumCollections.activityEvents, scumCollections.tradeGoods, scumCollections.tradeEvents]);
});
it("uses transaction, put, and delete for plugin-owned gift data", async () => {
@@ -235,6 +244,7 @@ describe("SCUM plugin feature module", () => {
expect(view.texts).toContain("最近活动记录");
expect(view.texts).toContain("Mira");
expect(view.texts).toContain("活动生成项");
expect(view.texts).toEqual(expect.arrayContaining(["物品列表", "最近商人交易", "Cargo Drop", "买入 × 2"]));
expect(pageSource).toContain("setEventEditorOpen(detailOpen(event))");
expect(pageSource).not.toContain("open: Boolean(view.eventId || view.eventName)");
});
@@ -242,7 +252,9 @@ describe("SCUM plugin feature module", () => {
it("renders gift definitions, claims, and delivery records", () => {
const definitions = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" });
expect(definitions.texts).toContain("礼包定义");
expect(definitions.texts).toContain("物品列表");
expect(definitions.texts).toContain("Starter Pack");
expect(definitions.texts).toContain("Cargo Drop");
expect(definitions.buttons.find((button) => button.label === "保存礼包")?.disabled).toBe(false);
expect(definitions.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["礼包周期", "适用玩家", "发放次数", "成就类型", "成就值", "礼包物品", "礼包命令"]));
const claims = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理", giftTab: "claims" });
@@ -270,12 +282,15 @@ describe("SCUM plugin feature module", () => {
it("deduplicates map entities, keeps every point, and computes custom map bounds", async () => {
const duplicateData: SCUMSurfaceData = { ...surfaceData, mapPoints: [{ id: "direct-player", subjectType: "player", subjectId: "76561198000000001", name: "Mira", x: 10, y: 20, z: 3 }, ...Array.from({ length: 260 }, (_, index) => ({ id: `poi-${index}`, name: `POI ${index}`, layer: "other", x: index * 10, y: index * 10 }))] };
expect(collectMapPoints(duplicateData)).toHaveLength(264);
expect(collectMapPoints(surfaceData).find((point) => point.vehicleId === "veh-1")).toMatchObject({ imagePath: "/original/BPC_Laika_C.webp", spawnCommand: "#spawnvehicle BPC_Laika_C" });
const bounds = resolveMapBounds({ customMapEnabled: true, centerX: 100000, centerY: 200000, widthKm: 4, heightKm: 2 });
expect(bounds).toEqual({ worldMinX: -100000, worldMinY: 100000, worldMaxX: 300000, worldMaxY: 300000 });
expect(mapPointStyle({ x: -100000, y: 100000 }, bounds)).toEqual({ left: "99%", top: "99%" });
const pluginData = pluginDataActions();
await saveMapSettings({ pluginData }, { customMapEnabled: true, centerX: 100000, centerY: 200000, widthKm: 4, heightKm: 2 });
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.mapSettings, "current", expect.objectContaining(bounds));
expect(pageSource).toContain('textField(point, "imagePath", "image_path")');
expect(pageSource).toContain('"className", "vehicleClass", "entityClass", "vehicleType"');
expect(pageSource).not.toContain("visible.slice(0, 240)");
});
@@ -286,7 +301,7 @@ describe("SCUM plugin feature module", () => {
expect(source).toContain("remote.access.request");
expect(source).not.toContain("input.templateKey");
expect(source).not.toContain("requestSCUMPageQueries");
expect(pageSource).toContain("setInterval(refresh, 10000)");
expect(pageSource).toContain("setInterval(refresh, 3000)");
expect(pageSource).toContain("clearInterval(interval)");
});
});