Restore durable log ingest and typed plugin projections

This commit is contained in:
npc0-hue
2026-09-02 10:20:30 +08:00
parent 40ac46ba17
commit 6018d8f0fc
61 changed files with 809 additions and 3157 deletions
@@ -199,6 +199,7 @@ func TestCompanionProductionSourceHasNoForbiddenAdapterPaths(t *testing.T) {
t.Fatal("resolve companion source directory")
}
forbidden := map[string]*regexp.Regexp{
"raw SQL or direct database access": regexp.MustCompile(`(?im)"(?:database/sql|github\.com/(?:mattn/go-sqlite3|go-sql-driver/mysql)|gorm\.io/gorm)"|\b(?:sql|db|database)\.(?:Open|Exec(?:Context)?|Query(?:Context)?|Prepare(?:Context)?)\s*\(`),
"unrestricted RCON or command execution": regexp.MustCompile(`(?i)\b(?:send|execute|run|dispatch)[a-z0-9_]*(?:rcon|rawcommand|command)\s*\(`),
"desktop automation or screen capture": regexp.MustCompile(`(?i)\b(?:tesseract|gosseract|screenshot|robotgo|autogui|keybd_event|mouse_event|sendinput)\b`),
"direct socket transport": regexp.MustCompile(`\bnet\.(?:Dial|DialTimeout)\s*\(`),
@@ -11,15 +11,10 @@ import (
)
const (
ConfigSchemaVersion = 1
PluginID = "game.scum"
ProfileKey = "scum-client-manager"
ProofEnvironment = "SCUM_COMPONENT_PROOF"
SCUMDatabaseFileEnvironment = "SCUM_DB_FILE"
TrajectorySourceSCUMSQLite = "scum-sqlite"
TrajectoryStoreSharedPlatformMySQL = "shared-platform-mysql"
DefaultTrajectoryCollectionIntervalSecs = 3
DefaultTrajectoryCollectionMaxRows = 500
ConfigSchemaVersion = 1
PluginID = "game.scum"
ProfileKey = "scum-client-manager"
ProofEnvironment = "SCUM_COMPONENT_PROOF"
)
var requiredCapabilities = []string{
@@ -46,7 +41,6 @@ type Config struct {
Capabilities []string `json:"capabilities" yaml:"capabilities"`
Timing TimingConfig `json:"timing" yaml:"timing"`
TLS TransportTLSConfig `json:"tls" yaml:"tls"`
Trajectory TrajectoryConfig `json:"trajectory" yaml:"trajectory"`
}
type PlatformConfig struct {
@@ -86,15 +80,6 @@ type TransportTLSConfig struct {
Policy string `json:"policy" yaml:"policy"`
}
type TrajectoryConfig struct {
Enabled bool `json:"enabled" yaml:"enabled"`
Source string `json:"source" yaml:"source"`
Store string `json:"store" yaml:"store"`
FileEnv string `json:"fileEnv" yaml:"fileEnv"`
IntervalSeconds int `json:"intervalSeconds" yaml:"intervalSeconds"`
MaxRows int `json:"maxRows" yaml:"maxRows"`
}
func LoadConfig(reader io.Reader) (Config, error) {
decoder := yaml.NewDecoder(reader)
decoder.KnownFields(true)
@@ -109,7 +94,6 @@ func LoadConfig(reader io.Reader) (Config, error) {
}
return Config{}, fmt.Errorf("decode companion config: %w", err)
}
config.applyDefaults()
if err := config.Validate(); err != nil {
return Config{}, err
}
@@ -118,24 +102,6 @@ func LoadConfig(reader io.Reader) (Config, error) {
return config, nil
}
func (config *Config) applyDefaults() {
if config.Trajectory.Source == "" {
config.Trajectory.Source = TrajectorySourceSCUMSQLite
}
if config.Trajectory.Store == "" {
config.Trajectory.Store = TrajectoryStoreSharedPlatformMySQL
}
if config.Trajectory.FileEnv == "" {
config.Trajectory.FileEnv = SCUMDatabaseFileEnvironment
}
if config.Trajectory.IntervalSeconds == 0 {
config.Trajectory.IntervalSeconds = DefaultTrajectoryCollectionIntervalSecs
}
if config.Trajectory.MaxRows == 0 {
config.Trajectory.MaxRows = DefaultTrajectoryCollectionMaxRows
}
}
func (config Config) Validate() error {
if config.SchemaVersion != ConfigSchemaVersion {
return fmt.Errorf("companion config schema version is unsupported")
@@ -168,43 +134,9 @@ func (config Config) Validate() error {
if config.Timing.HeartbeatIntervalSeconds < 5 || config.Timing.HeartbeatIntervalSeconds > 300 || config.Timing.CommandPollIntervalSeconds < 1 || config.Timing.CommandPollIntervalSeconds > 60 || config.Timing.RequestTimeoutSeconds < 1 || config.Timing.RequestTimeoutSeconds > 60 {
return fmt.Errorf("companion timing policy is invalid")
}
if err := config.Trajectory.Validate(); err != nil {
return err
}
return nil
}
func (config TrajectoryConfig) Validate() error {
if config.Source != TrajectorySourceSCUMSQLite || config.Store != TrajectoryStoreSharedPlatformMySQL {
return fmt.Errorf("SCUM trajectory collection mode is unsupported")
}
if !validCompanionEnvironmentName(config.FileEnv) {
return fmt.Errorf("SCUM database file environment name is invalid")
}
if config.IntervalSeconds < 1 || config.IntervalSeconds > 3600 || config.MaxRows < 1 || config.MaxRows > 5000 {
return fmt.Errorf("SCUM trajectory collection bounds are invalid")
}
return nil
}
func validCompanionEnvironmentName(value string) bool {
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
return false
}
for _, char := range value[1:] {
if char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '_' {
continue
}
return false
}
switch value {
case "PATH", "LD_PRELOAD", "DYLD_INSERT_LIBRARIES":
return false
default:
return true
}
}
func canonicalPlatformOrigin(value string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(value))
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path != "" && parsed.Path != "/" {
@@ -31,10 +31,3 @@ timing:
requestTimeoutSeconds: 15
tls:
policy: verify-system-roots
trajectory:
enabled: true
source: scum-sqlite
store: shared-platform-mysql
fileEnv: SCUM_DB_FILE
intervalSeconds: 3
maxRows: 500
@@ -1,10 +1,6 @@
package companion
import (
"os"
"strings"
"testing"
)
import "testing"
func TestCompanionVehicleHandlerCapabilityIsExplicitAndBounded(t *testing.T) {
base := append([]string(nil), requiredCapabilities...)
@@ -18,29 +14,3 @@ func TestCompanionVehicleHandlerCapabilityIsExplicitAndBounded(t *testing.T) {
t.Fatal("undeclared raw command handler capability must be rejected")
}
}
func TestLoadConfigDeclaresRawTrajectoryCollection(t *testing.T) {
config := loadTestConfig(t)
if !config.Trajectory.Enabled || config.Trajectory.Source != TrajectorySourceSCUMSQLite || config.Trajectory.Store != TrajectoryStoreSharedPlatformMySQL {
t.Fatalf("trajectory collection is not enabled with plugin-owned source/store: %+v", config.Trajectory)
}
if config.Trajectory.FileEnv != SCUMDatabaseFileEnvironment || config.Trajectory.IntervalSeconds != DefaultTrajectoryCollectionIntervalSecs || config.Trajectory.MaxRows != DefaultTrajectoryCollectionMaxRows {
t.Fatalf("trajectory collection did not use bounded defaults: %+v", config.Trajectory)
}
}
func TestTrajectoryConfigRejectsUnsafeEnvironmentNames(t *testing.T) {
fixture := strings.ReplaceAll(string(mustReadConfigFixture(t)), "fileEnv: SCUM_DB_FILE", "fileEnv: PATH")
if _, err := LoadConfig(strings.NewReader(fixture)); err == nil || !strings.Contains(err.Error(), "environment") {
t.Fatalf("expected reserved env name to be rejected, got %v", err)
}
}
func mustReadConfigFixture(t *testing.T) []byte {
t.Helper()
payload, err := os.ReadFile("config.yaml.example")
if err != nil {
t.Fatalf("read config fixture: %v", err)
}
return payload
}
@@ -1,62 +0,0 @@
package companion
import (
"context"
"testing"
"time"
)
type recordingConsoleLogStore struct {
ensureCalls int
records []ConsoleRecord
batches []SemanticEventBatch
}
func (store *recordingConsoleLogStore) EnsureSchema(context.Context) error {
store.ensureCalls++
return nil
}
func (store *recordingConsoleLogStore) StoreConsoleRecords(_ context.Context, records []ConsoleRecord) (int, error) {
store.records = append(store.records, records...)
return len(records), nil
}
func (store *recordingConsoleLogStore) StoreSemanticEventBatch(_ context.Context, batch SemanticEventBatch) (int, error) {
store.batches = append(store.batches, batch)
return len(batch.Events), nil
}
func TestConsoleLogCollectorStoresLiveConsoleEventAndSemanticBatch(t *testing.T) {
stamp := time.Date(2026, 8, 31, 4, 0, 0, 0, time.UTC)
store := &recordingConsoleLogStore{}
collector := NewConsoleLogCollector(nil, store, "server-1", "correlation-secret")
handle := collector.handleEvent(context.Background())
if err := handle(LogStreamEvent{ServerInstanceID: "server-1", StreamID: "stream-1", Source: "process", StreamKey: "stdout", Entry: LogEntry{Seq: 11, Timestamp: stamp, Line: "SCUM LOGIN 76561198000000001 10.0.0.1"}}); err != nil {
t.Fatalf("handle log event: %v", err)
}
if len(store.records) != 1 || store.records[0].ServerID != "server-1" || store.records[0].Stream != "stdout" || store.records[0].Sequence != 11 {
t.Fatalf("collector did not store raw console record: %#v", store.records)
}
if len(store.batches) != 1 || len(store.batches[0].Events) != 1 {
t.Fatalf("collector did not store semantic event batch: %#v", store.batches)
}
event := store.batches[0].Events[0]
if event.Type != "scum.login" || event.PlayerID != "76561198000000001" || event.NetworkCorrelation == "" || event.NetworkCorrelation == "10.0.0.1" {
t.Fatalf("unexpected semantic event: %#v", event)
}
}
func TestConsoleLogCollectorIgnoresNonConsoleLiveLogEvents(t *testing.T) {
store := &recordingConsoleLogStore{}
collector := NewConsoleLogCollector(nil, store, "server-1", "correlation-secret")
handle := collector.handleEvent(context.Background())
if err := handle(LogStreamEvent{ServerInstanceID: "server-1", StreamID: "stream-1", Source: "process", StreamKey: "scum.file", Entry: LogEntry{Seq: 12, Timestamp: time.Now().UTC(), Line: "not console"}}); err != nil {
t.Fatalf("handle non-console event: %v", err)
}
if len(store.records) != 0 || len(store.batches) != 0 {
t.Fatalf("non-console event was stored: records=%#v batches=%#v", store.records, store.batches)
}
}
@@ -1,127 +0,0 @@
package companion
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
)
func (store *SCUMSQLStore) StoreConsoleRecords(ctx context.Context, records []ConsoleRecord) (int, error) {
if store == nil || store.db == nil {
return 0, fmt.Errorf("SCUM plugin SQL store is not configured")
}
normalized := make([]ConsoleRecord, 0, len(records))
for _, record := range records {
value, err := normalizeConsoleRecord(record)
if err != nil {
return 0, err
}
normalized = append(normalized, value)
}
if len(normalized) == 0 {
return 0, nil
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("begin SCUM console log write: %w", err)
}
defer tx.Rollback()
stamp := time.Now().UTC()
for _, record := range normalized {
if _, err := tx.ExecContext(ctx, scumConsoleLogInsertSQL, scumConsoleRecordKey(record), record.ServerID, record.Stream, record.Sequence, record.OccurredAt, record.Text, stamp, stamp); err != nil {
return 0, fmt.Errorf("write SCUM console log: %w", err)
}
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit SCUM console logs: %w", err)
}
return len(normalized), nil
}
func (store *SCUMSQLStore) StoreSemanticEventBatch(ctx context.Context, batch SemanticEventBatch) (int, error) {
if store == nil || store.db == nil {
return 0, fmt.Errorf("SCUM plugin SQL store is not configured")
}
normalized := make([]SemanticEvent, 0, len(batch.Events))
for _, event := range batch.Events {
value, err := normalizeSemanticEvent(event)
if err != nil {
return 0, err
}
normalized = append(normalized, value)
}
if len(normalized) == 0 {
return 0, nil
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("begin SCUM semantic event write: %w", err)
}
defer tx.Rollback()
stamp := time.Now().UTC()
for _, event := range normalized {
if _, err := tx.ExecContext(ctx, scumSemanticEventInsertSQL, scumSemanticEventRecordKey(event), event.ServerID, event.Sequence, event.Type, event.PlayerID, nullText(event.DisplayName), event.OccurredAt, nullText(event.NetworkCorrelation), stamp, stamp); err != nil {
return 0, fmt.Errorf("write SCUM semantic event: %w", err)
}
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit SCUM semantic events: %w", err)
}
return len(normalized), nil
}
func normalizeConsoleRecord(record ConsoleRecord) (ConsoleRecord, error) {
record.ServerID = strings.TrimSpace(record.ServerID)
record.Stream = strings.TrimSpace(record.Stream)
record.Text = strings.TrimRight(record.Text, "\r\n")
if record.ServerID == "" || (record.Stream != "stdout" && record.Stream != "stderr") || record.Sequence == 0 || record.OccurredAt.IsZero() || strings.TrimSpace(record.Text) == "" || len(record.Text) > 8192 {
return ConsoleRecord{}, fmt.Errorf("SCUM console record is invalid")
}
record.OccurredAt = record.OccurredAt.UTC()
return record, nil
}
func normalizeSemanticEvent(event SemanticEvent) (SemanticEvent, error) {
event.ServerID = strings.TrimSpace(event.ServerID)
event.Type = strings.TrimSpace(event.Type)
event.PlayerID = strings.TrimSpace(event.PlayerID)
event.DisplayName = strings.TrimSpace(event.DisplayName)
event.NetworkCorrelation = strings.TrimSpace(event.NetworkCorrelation)
if event.ServerID == "" || event.Sequence == 0 || event.Type == "" || event.PlayerID == "" || event.OccurredAt.IsZero() || len(event.Type) > 80 || len(event.PlayerID) > 80 || len(event.DisplayName) > 120 || len(event.NetworkCorrelation) > 128 {
return SemanticEvent{}, fmt.Errorf("SCUM semantic event is invalid")
}
event.OccurredAt = event.OccurredAt.UTC()
return event, nil
}
func scumConsoleRecordKey(record ConsoleRecord) string {
digest := sha256.Sum256([]byte(strings.Join([]string{record.ServerID, record.Stream, fmt.Sprintf("%d", record.Sequence)}, "\x00")))
return hex.EncodeToString(digest[:])
}
func scumSemanticEventRecordKey(event SemanticEvent) string {
digest := sha256.Sum256([]byte(strings.Join([]string{event.ServerID, event.Type, event.PlayerID, fmt.Sprintf("%d", event.Sequence)}, "\x00")))
return hex.EncodeToString(digest[:])
}
const scumConsoleLogInsertSQL = `
INSERT INTO scum_console_logs (
record_key, server_instance_id, stream, sequence, occurred_at, line_text, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
occurred_at = VALUES(occurred_at),
line_text = VALUES(line_text),
updated_at = VALUES(updated_at)`
const scumSemanticEventInsertSQL = `
INSERT INTO scum_semantic_events (
record_key, server_instance_id, sequence, event_type, player_id, display_name, occurred_at, network_correlation, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name),
occurred_at = VALUES(occurred_at),
network_correlation = VALUES(network_correlation),
updated_at = VALUES(updated_at)`
@@ -10,9 +10,8 @@ import (
)
// SafeAdapter is intentionally narrow: it receives typed values only and has
// no direct transport, host-path, credential, or shell access. Game SQLite,
// RCON, and management-program text stay behind declared typed ports; plugin-owned
// durable writes use the dedicated SCUMSQLStore instead of browser page payloads.
// no direct transport, host-path, credential, or shell access. Protected SQL,
// RCON, and management-program text is forwarded to Run by Platform, not here.
type SafeAdapter interface {
ReadConfiguration(context.Context) (map[string]any, error)
PatchConfiguration(context.Context, map[string]any) (map[string]any, error)
@@ -2,22 +2,4 @@ module browser.local/plugins/scum-server-plugin/companion
go 1.25.1
require (
github.com/go-sql-driver/mysql v1.10.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.38.2
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.34.0 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
require gopkg.in/yaml.v3 v3.0.1
@@ -1,57 +1,4 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
@@ -1,66 +0,0 @@
package companion
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
)
func TestClientStreamLogEventsUsesComponentSessionBodyAndSSE(t *testing.T) {
stamp := time.Date(2026, 8, 31, 2, 0, 0, 0, time.UTC)
config := loadTestConfig(t)
client := newTestClient(t, config, roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.Method != http.MethodPost || request.URL.Scheme != "https" || request.URL.Host != "platform.example.test" || request.URL.Path != logEventsPath {
t.Fatalf("unexpected log stream request: %s %s", request.Method, request.URL.String())
}
if request.Header.Get("Authorization") != "" {
t.Fatalf("component session must stay in the typed JSON body, got Authorization header")
}
if request.Header.Get("Accept") != "text/event-stream" || request.Header.Get("Content-Type") != "application/json" {
t.Fatalf("unexpected log stream headers: %+v", request.Header)
}
var body logStreamRequest
decodeRequest(t, request, &body)
if body.SessionToken != "session-token" {
t.Fatalf("unexpected session token body: %#v", body)
}
logPayload, _ := json.Marshal(LogStreamEvent{ServerInstanceID: "server-1", StreamID: "stream-1", Source: "process", StreamKey: "stdout", LogSessionID: "session-live", SessionStartedAt: stamp, LatestSeq: 7, Entry: LogEntry{Seq: 7, Timestamp: stamp.Add(time.Second), Line: "SCUM LOGIN 76561198000000001 10.0.0.1", Redacted: true}})
bodyText := strings.Join([]string{
"event: ready",
"data: {\"serverInstanceId\":\"server-1\"}",
"",
"event: log",
"data: " + string(logPayload),
"",
": heartbeat",
"",
}, "\n")
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(bodyText)), Request: request}, nil
}), stamp)
client.mu.Lock()
client.sessionToken = "session-token"
client.sessionExpiresAt = stamp.Add(time.Hour)
client.mu.Unlock()
var events []LogStreamEvent
if err := client.StreamLogEvents(context.Background(), func(event LogStreamEvent) error {
events = append(events, event)
return nil
}); err != nil {
t.Fatalf("stream log events: %v", err)
}
if len(events) != 1 || events[0].Entry.Seq != 7 || events[0].Entry.Line != "SCUM LOGIN 76561198000000001 10.0.0.1" {
t.Fatalf("unexpected streamed log events: %#v", events)
}
}
func TestReadLogEventStreamRejectsMalformedLogEvent(t *testing.T) {
err := readLogEventStream(context.Background(), strings.NewReader("event: log\ndata: {not-json}\n\n"), func(LogStreamEvent) error { return nil })
if err == nil || !strings.Contains(err.Error(), "decode log event") {
t.Fatalf("expected malformed log event rejection, got %v", err)
}
}
@@ -1,184 +0,0 @@
package companion
import (
"context"
"database/sql"
"fmt"
"os"
"strings"
"time"
_ "modernc.org/sqlite"
)
var openSQLite = sql.Open
type SCUMSQLiteSource struct{ db *sql.DB }
func NewSCUMSQLiteSource(db *sql.DB) (*SCUMSQLiteSource, error) {
if db == nil {
return nil, fmt.Errorf("SCUM database source is required")
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
return &SCUMSQLiteSource{db: db}, nil
}
func OpenSCUMSQLiteSourceFromEnv(envName string) (*SCUMSQLiteSource, error) {
name := strings.TrimSpace(envName)
if name == "" {
name = SCUMDatabaseFileEnvironment
}
databaseFile := strings.TrimSpace(os.Getenv(name))
if databaseFile == "" {
return nil, fmt.Errorf("%s is required for SCUM database collection", name)
}
info, err := os.Stat(databaseFile)
if err != nil || info.IsDir() {
return nil, fmt.Errorf("%s must reference a readable SCUM database file", name)
}
db, err := openSQLite("sqlite", databaseFile)
if err != nil {
return nil, fmt.Errorf("open SCUM database source: %w", err)
}
if _, err := db.Exec("PRAGMA query_only = ON"); err != nil {
_ = db.Close()
return nil, fmt.Errorf("prepare SCUM database source for read-only collection: %w", err)
}
if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil {
_ = db.Close()
return nil, fmt.Errorf("prepare SCUM database source timeout: %w", err)
}
return NewSCUMSQLiteSource(db)
}
func (source *SCUMSQLiteSource) Close() error {
if source == nil || source.db == nil {
return nil
}
return source.db.Close()
}
func (source *SCUMSQLiteSource) ReadPositionRows(ctx context.Context, limit int) ([]map[string]any, error) {
return source.readRows(ctx, scumPositionRowsSQL, limit,
sql.Named("subjectType", nil),
sql.Named("subjectId", nil),
sql.Named("limit", boundedTrajectoryLimit(limit)),
)
}
func (source *SCUMSQLiteSource) ReadVehicleRows(ctx context.Context, limit int) ([]map[string]any, error) {
return source.readRows(ctx, scumVehicleRowsSQL, limit,
sql.Named("vehicleId", nil),
sql.Named("search", nil),
sql.Named("limit", boundedTrajectoryLimit(limit)),
)
}
func (source *SCUMSQLiteSource) readRows(ctx context.Context, query string, limit int, args ...any) ([]map[string]any, error) {
if source == nil || source.db == nil {
return nil, fmt.Errorf("SCUM database source is not configured")
}
rows, err := source.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("read SCUM database rows: %w", err)
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
return nil, fmt.Errorf("read SCUM database columns: %w", err)
}
maxRows := boundedTrajectoryLimit(limit)
result := make([]map[string]any, 0, maxRows)
values := make([]any, len(columns))
scanTargets := make([]any, len(columns))
for index := range values {
scanTargets[index] = &values[index]
}
for rows.Next() {
if len(result) >= maxRows {
break
}
if err := rows.Scan(scanTargets...); err != nil {
return nil, fmt.Errorf("scan SCUM database rows: %w", err)
}
row := make(map[string]any, len(columns))
for index, column := range columns {
row[column] = normalizeSQLiteValue(values[index])
}
result = append(result, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("read SCUM database rows: %w", err)
}
return result, nil
}
func boundedTrajectoryLimit(limit int) int {
if limit <= 0 {
return DefaultTrajectoryCollectionMaxRows
}
if limit > 5000 {
return 5000
}
return limit
}
func normalizeSQLiteValue(value any) any {
switch typed := value.(type) {
case []byte:
return string(typed)
case time.Time:
return typed.UTC().Format(time.RFC3339Nano)
default:
return typed
}
}
const scumPositionRowsSQL = `SELECT
'player' AS subjectType,
account.id AS subjectId,
CAST(profile.id AS TEXT) AS userProfileId,
CAST(prisoner.id AS TEXT) AS gamePlayerId,
NULL AS vehicleId,
CAST(entity.id AS TEXT) AS entityId,
NULL AS baseId,
entity.location_x AS x,
entity.location_y AS y,
entity.location_z AS z,
strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS observedAt
FROM user_profile profile
JOIN user account ON account.id = profile.user_id
JOIN prisoner ON prisoner.id = profile.prisoner_id
JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id
JOIN entity ON entity.id = prisoner_entity.entity_id
WHERE (:subjectType IS NULL OR :subjectType = 'player')
AND (:subjectId IS NULL OR account.id = :subjectId)
UNION ALL
SELECT
'vehicle', CAST(spawner.vehicle_entity_id AS TEXT), NULL, NULL,
CAST(spawner.vehicle_entity_id AS TEXT), CAST(entity.id AS TEXT), NULL,
entity.location_x, entity.location_y, entity.location_z,
strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch')
FROM vehicle_spawner spawner
JOIN entity ON entity.id = spawner.vehicle_entity_id
WHERE (:subjectType IS NULL OR :subjectType = 'vehicle')
AND (:subjectId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :subjectId)
LIMIT COALESCE(:limit, 500)`
const scumVehicleRowsSQL = `SELECT
CAST(spawner.vehicle_entity_id AS TEXT) AS vehicleId,
CAST(spawner.vehicle_entity_id AS TEXT) AS entityId,
entity.class AS className,
spawner.vehicle_alias AS label,
entity.location_x AS x,
entity.location_y AS y,
entity.location_z AS z,
strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch') AS lastAccessTime,
spawner.is_vehicle_functional AS isFunctional
FROM vehicle_spawner spawner
JOIN entity ON entity.id = spawner.vehicle_entity_id
WHERE (:vehicleId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :vehicleId)
AND (:search IS NULL OR spawner.vehicle_alias LIKE '%' || :search || '%' OR entity.class LIKE '%' || :search || '%')
ORDER BY spawner.vehicle_last_access_time DESC
LIMIT COALESCE(:limit, 500)`
@@ -1,80 +0,0 @@
package companion
import (
"context"
"database/sql"
"path/filepath"
"testing"
)
func TestSCUMSQLiteSourceReadsRawCoordinates(t *testing.T) {
databaseFile := filepath.Join(t.TempDir(), "SCUM.db")
db, err := sql.Open("sqlite", databaseFile)
if err != nil {
t.Fatalf("open sqlite fixture: %v", err)
}
defer db.Close()
for _, statement := range []string{
`CREATE TABLE user (id TEXT PRIMARY KEY)`,
`CREATE TABLE user_profile (id INTEGER PRIMARY KEY, user_id TEXT NOT NULL, prisoner_id INTEGER NOT NULL)`,
`CREATE TABLE prisoner (id INTEGER PRIMARY KEY, last_save_time INTEGER NOT NULL)`,
`CREATE TABLE prisoner_entity (prisoner_id INTEGER NOT NULL, entity_id INTEGER NOT NULL)`,
`CREATE TABLE entity (id INTEGER PRIMARY KEY, class TEXT, location_x REAL NOT NULL, location_y REAL NOT NULL, location_z REAL NOT NULL)`,
`CREATE TABLE vehicle_spawner (vehicle_entity_id INTEGER PRIMARY KEY, vehicle_alias TEXT, vehicle_last_access_time INTEGER NOT NULL, is_vehicle_functional INTEGER NOT NULL)`,
`INSERT INTO user (id) VALUES ('76561198000000001')`,
`INSERT INTO prisoner (id, last_save_time) VALUES (2001, 1788146999)`,
`INSERT INTO user_profile (id, user_id, prisoner_id) VALUES (1001, '76561198000000001', 2001)`,
`INSERT INTO entity (id, class, location_x, location_y, location_z) VALUES (3001, 'BP_Prisoner_C', 123.25, -456.5, 7.75)`,
`INSERT INTO prisoner_entity (prisoner_id, entity_id) VALUES (2001, 3001)`,
`INSERT INTO entity (id, class, location_x, location_y, location_z) VALUES (4001, 'BPC_Laika_C', -10.5, 20.25, 0)`,
`INSERT INTO vehicle_spawner (vehicle_entity_id, vehicle_alias, vehicle_last_access_time, is_vehicle_functional) VALUES (4001, 'Laika', 1788146988, 1)`,
} {
if _, err := db.Exec(statement); err != nil {
t.Fatalf("exec sqlite fixture statement %q: %v", statement, err)
}
}
source, err := NewSCUMSQLiteSource(db)
if err != nil {
t.Fatalf("create sqlite source: %v", err)
}
positions, err := source.ReadPositionRows(context.Background(), 10)
if err != nil {
t.Fatalf("read positions: %v", err)
}
vehicles, err := source.ReadVehicleRows(context.Background(), 10)
if err != nil {
t.Fatalf("read vehicles: %v", err)
}
player := rowByText(t, positions, "subjectType", "player")
vehiclePosition := rowByText(t, positions, "subjectType", "vehicle")
vehicle := rowByText(t, vehicles, "vehicleId", "4001")
assertNumber(t, player["x"], 123.25)
assertNumber(t, player["y"], -456.5)
assertNumber(t, player["z"], 7.75)
assertNumber(t, vehiclePosition["x"], -10.5)
assertNumber(t, vehiclePosition["y"], 20.25)
assertNumber(t, vehicle["x"], -10.5)
assertNumber(t, vehicle["y"], 20.25)
if vehicle["className"] != "BPC_Laika_C" || vehicle["label"] != "Laika" {
t.Fatalf("vehicle metadata changed: %+v", vehicle)
}
}
func rowByText(t *testing.T, rows []map[string]any, key string, value string) map[string]any {
t.Helper()
for _, row := range rows {
if textFromRow(row[key]) == value {
return row
}
}
t.Fatalf("missing row where %s=%s: %+v", key, value, rows)
return nil
}
func assertNumber(t *testing.T, value any, expected float64) {
t.Helper()
actual, ok := numberFromRow(value)
if !ok || actual != expected {
t.Fatalf("number = %v, want %v", value, expected)
}
}
@@ -1,400 +0,0 @@
package companion
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"os"
"strconv"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
)
const PlatformMySQLDSNEnvironment = "PLATFORM_MYSQL_DSN"
var openSQL = sql.Open
type SCUMSQLStore struct{ db *sql.DB }
type TrajectorySample struct {
ServerInstanceID string
SubjectType string
SubjectID string
SteamID string
UserProfileID string
GamePlayerID string
VehicleID string
EntityID string
BaseID string
DisplayName string
Label string
ClassName string
WorldX float64
WorldY float64
WorldZ *float64
ObservedAt time.Time
SampledAt time.Time
Source string
}
func NewSCUMSQLStore(db *sql.DB) (*SCUMSQLStore, error) {
if db == nil {
return nil, fmt.Errorf("platform SQL handle is required")
}
return &SCUMSQLStore{db: db}, nil
}
func (store *SCUMSQLStore) Close() error {
if store == nil || store.db == nil {
return nil
}
return store.db.Close()
}
func OpenSCUMSQLStoreFromEnv(envName string) (*SCUMSQLStore, error) {
name := strings.TrimSpace(envName)
if name == "" {
name = PlatformMySQLDSNEnvironment
}
dsn := strings.TrimSpace(os.Getenv(name))
if dsn == "" {
return nil, fmt.Errorf("%s is required for SCUM plugin SQL storage", name)
}
db, err := openSQL("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("open SCUM plugin SQL storage: %w", err)
}
return NewSCUMSQLStore(db)
}
func (store *SCUMSQLStore) EnsureSchema(ctx context.Context) error {
if store == nil || store.db == nil {
return fmt.Errorf("SCUM plugin SQL store is not configured")
}
for _, statement := range scumSQLStoreMigrations {
if _, err := store.db.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("apply SCUM plugin SQL migration: %w", err)
}
}
return nil
}
func (store *SCUMSQLStore) StorePositionRows(ctx context.Context, serverInstanceID string, rows []map[string]any, sampledAt time.Time) (int, error) {
samples, err := TrajectorySamplesFromPositionRows(serverInstanceID, rows, sampledAt)
if err != nil {
return 0, err
}
return store.StoreTrajectorySamples(ctx, samples)
}
func (store *SCUMSQLStore) StoreVehicleRows(ctx context.Context, serverInstanceID string, rows []map[string]any, sampledAt time.Time) (int, error) {
samples, err := TrajectorySamplesFromVehicleRows(serverInstanceID, rows, sampledAt)
if err != nil {
return 0, err
}
return store.StoreTrajectorySamples(ctx, samples)
}
func (store *SCUMSQLStore) StoreTrajectorySamples(ctx context.Context, samples []TrajectorySample) (int, error) {
if store == nil || store.db == nil {
return 0, fmt.Errorf("SCUM plugin SQL store is not configured")
}
normalized := make([]TrajectorySample, 0, len(samples))
for _, sample := range samples {
value, err := normalizeTrajectorySample(sample)
if err != nil {
return 0, err
}
normalized = append(normalized, value)
}
if len(normalized) == 0 {
return 0, nil
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("begin SCUM trajectory write: %w", err)
}
defer tx.Rollback()
stamp := time.Now().UTC()
for _, sample := range normalized {
args := scumTrajectoryInsertArgs(sample, stamp)
if _, err := tx.ExecContext(ctx, scumTrajectoryInsertSQL, args...); err != nil {
return 0, fmt.Errorf("write SCUM trajectory sample: %w", err)
}
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit SCUM trajectory samples: %w", err)
}
return len(normalized), nil
}
func TrajectorySamplesFromPositionRows(serverInstanceID string, rows []map[string]any, sampledAt time.Time) ([]TrajectorySample, error) {
result := make([]TrajectorySample, 0, len(rows))
for _, row := range rows {
subjectType := textFromRow(row["subjectType"])
if subjectType != "player" && subjectType != "vehicle" {
continue
}
x, xOK := numberFromRow(row["x"])
y, yOK := numberFromRow(row["y"])
if !xOK || !yOK {
continue
}
z := optionalNumberFromRow(row["z"])
subjectID := textFromRow(row["subjectId"])
sample := TrajectorySample{
ServerInstanceID: serverInstanceID,
SubjectType: subjectType,
SubjectID: subjectID,
UserProfileID: textFromRow(row["userProfileId"]),
GamePlayerID: textFromRow(row["gamePlayerId"]),
VehicleID: textFromRow(row["vehicleId"]),
EntityID: textFromRow(row["entityId"]),
BaseID: textFromRow(row["baseId"]),
WorldX: x,
WorldY: y,
WorldZ: z,
ObservedAt: timestampFromRow(row["observedAt"], sampledAt),
SampledAt: sampledAt,
Source: "plugin.sql.scum.positions",
}
if sample.SubjectType == "player" {
sample.SteamID = sample.SubjectID
}
result = append(result, sample)
}
return result, nil
}
func TrajectorySamplesFromVehicleRows(serverInstanceID string, rows []map[string]any, sampledAt time.Time) ([]TrajectorySample, error) {
result := make([]TrajectorySample, 0, len(rows))
for _, row := range rows {
x, xOK := numberFromRow(row["x"])
y, yOK := numberFromRow(row["y"])
if !xOK || !yOK {
continue
}
vehicleID := textFromRow(row["vehicleId"])
result = append(result, TrajectorySample{
ServerInstanceID: serverInstanceID,
SubjectType: "vehicle",
SubjectID: vehicleID,
VehicleID: vehicleID,
EntityID: textFromRow(row["entityId"]),
Label: textFromRow(row["label"]),
ClassName: textFromRow(row["className"]),
WorldX: x,
WorldY: y,
WorldZ: optionalNumberFromRow(row["z"]),
ObservedAt: timestampFromRow(row["lastAccessTime"], sampledAt),
SampledAt: sampledAt,
Source: "plugin.sql.scum.vehicles",
})
}
return result, nil
}
func normalizeTrajectorySample(sample TrajectorySample) (TrajectorySample, error) {
sample.ServerInstanceID = strings.TrimSpace(sample.ServerInstanceID)
sample.SubjectType = strings.TrimSpace(sample.SubjectType)
sample.SubjectID = strings.TrimSpace(sample.SubjectID)
sample.SteamID = strings.TrimSpace(sample.SteamID)
sample.UserProfileID = strings.TrimSpace(sample.UserProfileID)
sample.GamePlayerID = strings.TrimSpace(sample.GamePlayerID)
sample.VehicleID = strings.TrimSpace(sample.VehicleID)
sample.EntityID = strings.TrimSpace(sample.EntityID)
sample.BaseID = strings.TrimSpace(sample.BaseID)
sample.DisplayName = strings.TrimSpace(sample.DisplayName)
sample.Label = strings.TrimSpace(sample.Label)
sample.ClassName = strings.TrimSpace(sample.ClassName)
sample.Source = strings.TrimSpace(sample.Source)
if sample.ServerInstanceID == "" || sample.SubjectID == "" || sample.SampledAt.IsZero() || !validTrajectorySubjectType(sample.SubjectType) || !finite(sample.WorldX) || !finite(sample.WorldY) || sample.WorldZ != nil && !finite(*sample.WorldZ) {
return TrajectorySample{}, fmt.Errorf("SCUM trajectory sample is invalid")
}
if sample.ObservedAt.IsZero() {
sample.ObservedAt = sample.SampledAt
}
if sample.Source == "" {
sample.Source = "plugin.sql.scum"
}
if !boundedTrajectoryTexts(sample) {
return TrajectorySample{}, fmt.Errorf("SCUM trajectory sample text is too long")
}
sample.ObservedAt = sample.ObservedAt.UTC()
sample.SampledAt = sample.SampledAt.UTC()
return sample, nil
}
func scumTrajectoryInsertArgs(sample TrajectorySample, stamp time.Time) []any {
return []any{
scumTrajectoryRecordKey(sample), sample.ServerInstanceID, sample.SubjectType, sample.SubjectID,
nullText(sample.SteamID), nullText(sample.UserProfileID), nullText(sample.GamePlayerID), nullText(sample.VehicleID), nullText(sample.EntityID), nullText(sample.BaseID),
nullText(sample.DisplayName), nullText(sample.Label), nullText(sample.ClassName), sample.WorldX, sample.WorldY, nullFloat(sample.WorldZ), sample.ObservedAt, sample.SampledAt, sample.Source, stamp, stamp,
}
}
func scumTrajectoryRecordKey(sample TrajectorySample) string {
digest := sha256.Sum256([]byte(strings.Join([]string{sample.ServerInstanceID, sample.SubjectType, sample.SubjectID, sample.SampledAt.UTC().Format(time.RFC3339Nano)}, "\x00")))
return hex.EncodeToString(digest[:])
}
func validTrajectorySubjectType(value string) bool { return value == "player" || value == "vehicle" }
func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }
func nullText(value string) any {
if value == "" {
return nil
}
return value
}
func nullFloat(value *float64) any {
if value == nil {
return nil
}
return *value
}
func boundedTrajectoryTexts(sample TrajectorySample) bool {
return len(sample.ServerInstanceID) <= 96 && len(sample.SubjectType) <= 16 && len(sample.SubjectID) <= 128 && len(sample.SteamID) <= 32 && len(sample.UserProfileID) <= 96 && len(sample.GamePlayerID) <= 96 && len(sample.VehicleID) <= 96 && len(sample.EntityID) <= 96 && len(sample.BaseID) <= 96 && len(sample.DisplayName) <= 120 && len(sample.Label) <= 120 && len(sample.ClassName) <= 160 && len(sample.Source) <= 80
}
func textFromRow(value any) string {
switch typed := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(typed)
case json.Number:
return typed.String()
default:
return strings.TrimSpace(fmt.Sprint(typed))
}
}
func numberFromRow(value any) (float64, bool) {
switch typed := value.(type) {
case nil:
return 0, false
case float64:
return typed, finite(typed)
case float32:
value := float64(typed)
return value, finite(value)
case int:
return float64(typed), true
case int64:
return float64(typed), true
case int32:
return float64(typed), true
case json.Number:
value, err := typed.Float64()
return value, err == nil && finite(value)
case string:
value, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
return value, err == nil && finite(value)
default:
return 0, false
}
}
func optionalNumberFromRow(value any) *float64 {
number, ok := numberFromRow(value)
if !ok {
return nil
}
return &number
}
func timestampFromRow(value any, fallback time.Time) time.Time {
text := textFromRow(value)
if text == "" {
return fallback
}
parsed, err := time.Parse(time.RFC3339Nano, text)
if err != nil {
return fallback
}
return parsed
}
var scumSQLStoreMigrations = []string{`
CREATE TABLE IF NOT EXISTS scum_trajectories (
record_key CHAR(64) PRIMARY KEY,
server_instance_id VARCHAR(96) NOT NULL,
subject_type VARCHAR(16) NOT NULL,
subject_id VARCHAR(128) NOT NULL,
steam_id VARCHAR(32) NULL,
user_profile_id VARCHAR(96) NULL,
game_player_id VARCHAR(96) NULL,
vehicle_id VARCHAR(96) NULL,
entity_id VARCHAR(96) NULL,
base_id VARCHAR(96) NULL,
display_name VARCHAR(120) NULL,
label VARCHAR(120) NULL,
class_name VARCHAR(160) NULL,
world_x DOUBLE NOT NULL,
world_y DOUBLE NOT NULL,
world_z DOUBLE NULL,
observed_at DATETIME(6) NOT NULL,
sampled_at DATETIME(6) NOT NULL,
source VARCHAR(80) NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
UNIQUE KEY scum_trajectories_sample_uq (server_instance_id, subject_type, subject_id, sampled_at),
KEY scum_trajectories_subject_idx (server_instance_id, subject_type, subject_id, observed_at),
KEY scum_trajectories_sampled_idx (server_instance_id, sampled_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `
CREATE TABLE IF NOT EXISTS scum_console_logs (
record_key CHAR(64) PRIMARY KEY,
server_instance_id VARCHAR(96) NOT NULL,
stream VARCHAR(16) NOT NULL,
sequence BIGINT UNSIGNED NOT NULL,
occurred_at DATETIME(6) NOT NULL,
line_text TEXT NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
UNIQUE KEY scum_console_logs_stream_uq (server_instance_id, stream, sequence),
KEY scum_console_logs_time_idx (server_instance_id, occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `
CREATE TABLE IF NOT EXISTS scum_semantic_events (
record_key CHAR(64) PRIMARY KEY,
server_instance_id VARCHAR(96) NOT NULL,
sequence BIGINT UNSIGNED NOT NULL,
event_type VARCHAR(80) NOT NULL,
player_id VARCHAR(80) NOT NULL,
display_name VARCHAR(120) NULL,
occurred_at DATETIME(6) NOT NULL,
network_correlation VARCHAR(128) NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
UNIQUE KEY scum_semantic_events_uq (server_instance_id, event_type, player_id, sequence),
KEY scum_semantic_events_player_idx (server_instance_id, player_id, occurred_at),
KEY scum_semantic_events_type_idx (server_instance_id, event_type, occurred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`}
const scumTrajectoryInsertSQL = `
INSERT INTO scum_trajectories (
record_key, server_instance_id, subject_type, subject_id, steam_id, user_profile_id, game_player_id, vehicle_id, entity_id, base_id,
display_name, label, class_name, world_x, world_y, world_z, observed_at, sampled_at, source, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
steam_id = VALUES(steam_id),
user_profile_id = VALUES(user_profile_id),
game_player_id = VALUES(game_player_id),
vehicle_id = VALUES(vehicle_id),
entity_id = VALUES(entity_id),
base_id = VALUES(base_id),
display_name = VALUES(display_name),
label = VALUES(label),
class_name = VALUES(class_name),
world_x = VALUES(world_x),
world_y = VALUES(world_y),
world_z = VALUES(world_z),
observed_at = VALUES(observed_at),
source = VALUES(source),
updated_at = VALUES(updated_at)`
@@ -1,238 +0,0 @@
package companion
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"strings"
"sync"
"testing"
"time"
)
const recordingSQLDriverName = "scum_companion_recording_sql"
var (
recordingSQLDriverOnce sync.Once
activeSQLRecorder *sqlRecorder
)
type sqlRecorder struct {
mu sync.Mutex
dsn string
statements []string
args [][]driver.NamedValue
commits int
}
func (recorder *sqlRecorder) append(query string, args []driver.NamedValue) {
recorder.mu.Lock()
defer recorder.mu.Unlock()
recorder.statements = append(recorder.statements, query)
recorder.args = append(recorder.args, append([]driver.NamedValue(nil), args...))
}
func (recorder *sqlRecorder) committed() {
recorder.mu.Lock()
defer recorder.mu.Unlock()
recorder.commits++
}
type recordingSQLDriver struct{}
type recordingSQLConn struct{ recorder *sqlRecorder }
type recordingSQLTx struct{ recorder *sqlRecorder }
func (recordingSQLDriver) Open(dsn string) (driver.Conn, error) {
if activeSQLRecorder == nil {
return nil, errors.New("recording SQL recorder is not configured")
}
activeSQLRecorder.dsn = dsn
return &recordingSQLConn{recorder: activeSQLRecorder}, nil
}
func (conn *recordingSQLConn) Prepare(string) (driver.Stmt, error) {
return nil, errors.New("prepare is not supported")
}
func (conn *recordingSQLConn) Close() error { return nil }
func (conn *recordingSQLConn) Begin() (driver.Tx, error) {
return &recordingSQLTx{recorder: conn.recorder}, nil
}
func (conn *recordingSQLConn) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) {
return &recordingSQLTx{recorder: conn.recorder}, nil
}
func (conn *recordingSQLConn) ExecContext(_ context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
conn.recorder.append(query, args)
return driver.RowsAffected(1), nil
}
func (tx *recordingSQLTx) Commit() error { tx.recorder.committed(); return nil }
func (tx *recordingSQLTx) Rollback() error { return nil }
func TestSCUMSQLStoreWritesTrajectorySamplesWithoutCoordinateConversion(t *testing.T) {
db, recorder := newRecordingSQLDB(t, "direct-db")
store, err := NewSCUMSQLStore(db)
if err != nil {
t.Fatalf("create SCUM SQL store: %v", err)
}
if err := store.EnsureSchema(context.Background()); err != nil {
t.Fatalf("ensure schema: %v", err)
}
z := 7.75
sampledAt := time.Date(2026, 8, 28, 7, 30, 0, 123456000, time.UTC)
written, err := store.StoreTrajectorySamples(context.Background(), []TrajectorySample{{
ServerInstanceID: "server-1", SubjectType: "player", SubjectID: "76561198000000001", SteamID: "76561198000000001", UserProfileID: "profile-1", GamePlayerID: "player-1",
WorldX: 123.5, WorldY: -456.25, WorldZ: &z, ObservedAt: sampledAt.Add(-time.Second), SampledAt: sampledAt, Source: "plugin.sql.scum.positions",
}})
if err != nil || written != 1 {
t.Fatalf("store trajectory sample: written=%d err=%v", written, err)
}
if recorder.commits != 1 {
t.Fatalf("trajectory write did not commit once: %d", recorder.commits)
}
insertIndex := findStatement(recorder.statements, "INSERT INTO scum_trajectories")
if insertIndex < 0 {
t.Fatalf("missing trajectory insert statement: %v", recorder.statements)
}
insert := recorder.statements[insertIndex]
if !strings.Contains(insert, "world_x") || !strings.Contains(insert, "world_y") || !strings.Contains(insert, "world_z") || strings.Contains(insert, "map_x") || strings.Contains(insert, "pixel") {
t.Fatalf("trajectory SQL does not use raw world coordinate columns: %s", insert)
}
args := recorder.args[insertIndex]
if args[13].Value != 123.5 || args[14].Value != -456.25 || args[15].Value != 7.75 {
t.Fatalf("coordinates were changed before storage: %+v", args[13:16])
}
}
func TestSCUMSQLStoreWritesConsoleAndSemanticEventsToPluginTables(t *testing.T) {
db, recorder := newRecordingSQLDB(t, "console-db")
store, err := NewSCUMSQLStore(db)
if err != nil {
t.Fatalf("create SCUM SQL store: %v", err)
}
stamp := time.Date(2026, 8, 31, 3, 0, 0, 0, time.UTC)
records := []ConsoleRecord{{ServerID: "server-1", Stream: "stdout", Sequence: 9, OccurredAt: stamp, Text: "SCUM LOGIN 76561198000000001 10.0.0.1"}}
written, err := store.StoreConsoleRecords(context.Background(), records)
if err != nil || written != 1 {
t.Fatalf("store console records: written=%d err=%v", written, err)
}
batch := ParseConsoleRecords("server-1", records, "correlation-secret")
if len(batch.Events) != 1 || batch.Events[0].NetworkCorrelation == "" {
t.Fatalf("expected one correlated semantic event: %#v", batch)
}
semanticWritten, err := store.StoreSemanticEventBatch(context.Background(), batch)
if err != nil || semanticWritten != 1 {
t.Fatalf("store semantic events: written=%d err=%v", semanticWritten, err)
}
if recorder.commits != 2 {
t.Fatalf("console and semantic writes did not commit once each: %d", recorder.commits)
}
consoleIndex := findStatement(recorder.statements, "INSERT INTO scum_console_logs")
if consoleIndex < 0 {
t.Fatalf("missing console insert statement: %v", recorder.statements)
}
consoleInsert := recorder.statements[consoleIndex]
if !strings.Contains(consoleInsert, "line_text") || strings.Contains(consoleInsert, "platform_logs") {
t.Fatalf("console SQL must write the SCUM plugin table only: %s", consoleInsert)
}
consoleArgs := recorder.args[consoleIndex]
if consoleArgs[1].Value != "server-1" || consoleArgs[2].Value != "stdout" || !driverNumberEquals(consoleArgs[3].Value, 9) || consoleArgs[5].Value != records[0].Text {
t.Fatalf("unexpected console insert args: %+v", consoleArgs)
}
semanticIndex := findStatement(recorder.statements, "INSERT INTO scum_semantic_events")
if semanticIndex < 0 {
t.Fatalf("missing semantic event insert statement: %v", recorder.statements)
}
semanticInsert := recorder.statements[semanticIndex]
if strings.Contains(semanticInsert, "platform_logs") || strings.Contains(semanticInsert, "run_logs") {
t.Fatalf("semantic SQL must write the SCUM plugin table only: %s", semanticInsert)
}
semanticArgs := recorder.args[semanticIndex]
if semanticArgs[1].Value != "server-1" || !driverNumberEquals(semanticArgs[2].Value, 9) || semanticArgs[3].Value != "scum.login" || semanticArgs[4].Value != "76561198000000001" {
t.Fatalf("unexpected semantic insert args: %+v", semanticArgs)
}
correlation, ok := semanticArgs[7].Value.(string)
if !ok || correlation == "10.0.0.1" || len(correlation) != 64 {
t.Fatalf("semantic event stored raw or missing network correlation: %+v", semanticArgs[7])
}
}
func TestTrajectorySamplesFromSCUMRowsKeepWorldCoordinates(t *testing.T) {
sampledAt := time.Date(2026, 8, 28, 8, 0, 0, 0, time.UTC)
positionSamples, err := TrajectorySamplesFromPositionRows("server-1", []map[string]any{
{"subjectType": "player", "subjectId": "76561198000000001", "userProfileId": "profile-1", "gamePlayerId": "player-1", "x": 10.25, "y": -20.5, "z": 3.75, "observedAt": "2026-08-28T07:59:00Z"},
{"subjectType": "base", "subjectId": "base-1", "x": 1, "y": 2, "z": 0},
}, sampledAt)
if err != nil || len(positionSamples) != 1 {
t.Fatalf("position samples=%+v err=%v", positionSamples, err)
}
if positionSamples[0].WorldX != 10.25 || positionSamples[0].WorldY != -20.5 || positionSamples[0].WorldZ == nil || *positionSamples[0].WorldZ != 3.75 || positionSamples[0].Source != "plugin.sql.scum.positions" {
t.Fatalf("position coordinates were not preserved: %+v", positionSamples[0])
}
vehicleSamples, err := TrajectorySamplesFromVehicleRows("server-1", []map[string]any{{"vehicleId": "vehicle-1", "entityId": "entity-1", "className": "BPC_Laika_C", "label": "Laika", "x": "400.5", "y": 200, "z": 0, "lastAccessTime": "2026-08-28T07:58:00Z"}}, sampledAt)
if err != nil || len(vehicleSamples) != 1 {
t.Fatalf("vehicle samples=%+v err=%v", vehicleSamples, err)
}
if vehicleSamples[0].SubjectType != "vehicle" || vehicleSamples[0].WorldX != 400.5 || vehicleSamples[0].WorldY != 200 || vehicleSamples[0].ClassName != "BPC_Laika_C" || vehicleSamples[0].Source != "plugin.sql.scum.vehicles" {
t.Fatalf("vehicle coordinates were not preserved: %+v", vehicleSamples[0])
}
}
func TestOpenSCUMSQLStoreFromEnvUsesSharedPlatformDSNName(t *testing.T) {
recorder := &sqlRecorder{}
activeSQLRecorder = recorder
previousOpenSQL := openSQL
t.Cleanup(func() { openSQL = previousOpenSQL })
openSQL = func(driverName, dsn string) (*sql.DB, error) {
if driverName != "mysql" {
t.Fatalf("unexpected SQL driver: %s", driverName)
}
return sql.Open(recordingSQLDriverName, dsn)
}
t.Setenv(PlatformMySQLDSNEnvironment, "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true")
store, err := OpenSCUMSQLStoreFromEnv(PlatformMySQLDSNEnvironment)
if err != nil {
t.Fatalf("open store from shared env: %v", err)
}
if err := store.EnsureSchema(context.Background()); err != nil {
t.Fatalf("ensure schema from shared env: %v", err)
}
store.db.Close()
if recorder.dsn != "platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true" {
t.Fatalf("store did not use the shared platform DSN environment: %q", recorder.dsn)
}
}
func newRecordingSQLDB(t *testing.T, dsn string) (*sql.DB, *sqlRecorder) {
t.Helper()
recordingSQLDriverOnce.Do(func() { sql.Register(recordingSQLDriverName, recordingSQLDriver{}) })
recorder := &sqlRecorder{}
activeSQLRecorder = recorder
db, err := sql.Open(recordingSQLDriverName, dsn)
if err != nil {
t.Fatalf("open recording SQL db: %v", err)
}
return db, recorder
}
func findStatement(statements []string, prefix string) int {
for index, statement := range statements {
if strings.Contains(statement, prefix) {
return index
}
}
return -1
}
func driverNumberEquals(value any, want int64) bool {
switch typed := value.(type) {
case int:
return int64(typed) == want
case int64:
return typed == want
case uint64:
return typed == uint64(want)
default:
return false
}
}
@@ -1,166 +0,0 @@
package companion
import (
"context"
"fmt"
"strings"
"sync"
"time"
)
type TrajectorySource interface {
ReadPositionRows(context.Context, int) ([]map[string]any, error)
ReadVehicleRows(context.Context, int) ([]map[string]any, error)
}
type TrajectoryStore interface {
EnsureSchema(context.Context) error
StorePositionRows(context.Context, string, []map[string]any, time.Time) (int, error)
StoreVehicleRows(context.Context, string, []map[string]any, time.Time) (int, error)
}
type TrajectoryCollectionReport struct {
CollectedAt time.Time
PositionRows int
VehicleRows int
StoredSamples int
Status string
Reason string
}
type TrajectoryCollector struct {
Source TrajectorySource
Store TrajectoryStore
ServerInstanceID string
Interval time.Duration
MaxRows int
Now func() time.Time
schemaOnce sync.Once
schemaErr error
}
func NewTrajectoryCollector(config Config, source TrajectorySource, store TrajectoryStore) *TrajectoryCollector {
return &TrajectoryCollector{
Source: source,
Store: store,
ServerInstanceID: config.Component.ServerInstanceID,
Interval: time.Duration(config.Trajectory.IntervalSeconds) * time.Second,
MaxRows: config.Trajectory.MaxRows,
}
}
func (collector *TrajectoryCollector) CollectOnce(ctx context.Context) (TrajectoryCollectionReport, error) {
if collector == nil || collector.Source == nil || collector.Store == nil || strings.TrimSpace(collector.ServerInstanceID) == "" {
return TrajectoryCollectionReport{}, fmt.Errorf("SCUM trajectory collector is not configured")
}
collector.schemaOnce.Do(func() { collector.schemaErr = collector.Store.EnsureSchema(ctx) })
if collector.schemaErr != nil {
return TrajectoryCollectionReport{}, collector.schemaErr
}
sampledAt := collector.clock()().UTC()
report := TrajectoryCollectionReport{CollectedAt: sampledAt, Status: "healthy"}
positions, err := collector.Source.ReadPositionRows(ctx, collector.MaxRows)
if err != nil {
report.Status, report.Reason = "degraded", "position collection failed"
return report, err
}
report.PositionRows = len(positions)
written, err := collector.Store.StorePositionRows(ctx, collector.ServerInstanceID, positions, sampledAt)
if err != nil {
report.Status, report.Reason = "degraded", "position storage failed"
return report, err
}
report.StoredSamples += written
vehicles, err := collector.Source.ReadVehicleRows(ctx, collector.MaxRows)
if err != nil {
report.Status, report.Reason = "degraded", "vehicle collection failed"
return report, err
}
report.VehicleRows = len(vehicles)
written, err = collector.Store.StoreVehicleRows(ctx, collector.ServerInstanceID, vehicles, sampledAt)
if err != nil {
report.Status, report.Reason = "degraded", "vehicle storage failed"
return report, err
}
report.StoredSamples += written
report.Reason = "raw world coordinates stored"
return report, nil
}
func (collector *TrajectoryCollector) Run(ctx context.Context, status *TrajectoryCollectionStatus) error {
interval := collector.Interval
if interval < time.Second {
interval = time.Duration(DefaultTrajectoryCollectionIntervalSecs) * time.Second
}
if report, err := collector.CollectOnce(ctx); status != nil {
status.Record(report, err)
} else if err != nil {
return err
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
report, err := collector.CollectOnce(ctx)
if status != nil {
status.Record(report, err)
continue
}
if err != nil {
return err
}
}
}
}
func (collector *TrajectoryCollector) clock() func() time.Time {
if collector.Now != nil {
return collector.Now
}
return time.Now
}
type TrajectoryCollectionStatus struct {
mu sync.Mutex
latest TrajectoryCollectionReport
err error
}
func (status *TrajectoryCollectionStatus) Record(report TrajectoryCollectionReport, err error) {
if status == nil {
return
}
status.mu.Lock()
defer status.mu.Unlock()
status.latest = report
status.err = err
}
func (status *TrajectoryCollectionStatus) HealthReport() HealthReport {
if status == nil {
return HealthReport{Status: "healthy", Reason: "typed companion dispatcher ready"}
}
status.mu.Lock()
defer status.mu.Unlock()
if status.latest.Status == "healthy" && status.err == nil {
return HealthReport{Status: "healthy", Reason: safeHealthReason(status.latest.Reason, "typed companion dispatcher ready")}
}
if !status.latest.CollectedAt.IsZero() && status.err == nil {
return HealthReport{Status: "healthy", Reason: "raw world coordinate collection ready"}
}
if status.err != nil {
return HealthReport{Status: "degraded", Reason: safeHealthReason(status.latest.Reason, "trajectory collection waiting for source data")}
}
return HealthReport{Status: "degraded", Reason: "trajectory collection waiting for first sample"}
}
func safeHealthReason(value string, fallback string) string {
value = strings.TrimSpace(value)
if value == "" {
return fallback
}
return value
}
@@ -1,80 +0,0 @@
package companion
import (
"context"
"testing"
"time"
)
type trajectorySourceFixture struct {
positions []map[string]any
vehicles []map[string]any
limits []int
}
func (source *trajectorySourceFixture) ReadPositionRows(_ context.Context, limit int) ([]map[string]any, error) {
source.limits = append(source.limits, limit)
return source.positions, nil
}
func (source *trajectorySourceFixture) ReadVehicleRows(_ context.Context, limit int) ([]map[string]any, error) {
source.limits = append(source.limits, limit)
return source.vehicles, nil
}
type trajectoryStoreFixture struct {
ensureCalls int
samples []TrajectorySample
}
func (store *trajectoryStoreFixture) EnsureSchema(context.Context) error {
store.ensureCalls++
return nil
}
func (store *trajectoryStoreFixture) StorePositionRows(_ context.Context, serverInstanceID string, rows []map[string]any, sampledAt time.Time) (int, error) {
samples, err := TrajectorySamplesFromPositionRows(serverInstanceID, rows, sampledAt)
if err != nil {
return 0, err
}
store.samples = append(store.samples, samples...)
return len(samples), nil
}
func (store *trajectoryStoreFixture) StoreVehicleRows(_ context.Context, serverInstanceID string, rows []map[string]any, sampledAt time.Time) (int, error) {
samples, err := TrajectorySamplesFromVehicleRows(serverInstanceID, rows, sampledAt)
if err != nil {
return 0, err
}
store.samples = append(store.samples, samples...)
return len(samples), nil
}
func TestTrajectoryCollectorStoresRawSCUMWorldCoordinates(t *testing.T) {
sampledAt := time.Date(2026, 8, 31, 3, 30, 0, 0, time.UTC)
source := &trajectorySourceFixture{
positions: []map[string]any{{"subjectType": "player", "subjectId": "76561198000000001", "gamePlayerId": "player-1", "x": 123.25, "y": -456.5, "z": 7.75, "observedAt": "2026-08-31T03:29:59Z"}},
vehicles: []map[string]any{{"vehicleId": "vehicle-1", "entityId": "entity-1", "className": "BPC_Laika_C", "label": "Laika", "x": -10.5, "y": 20.25, "z": 0}},
}
store := &trajectoryStoreFixture{}
collector := &TrajectoryCollector{Source: source, Store: store, ServerInstanceID: "server-1", MaxRows: 777, Now: func() time.Time { return sampledAt }}
report, err := collector.CollectOnce(context.Background())
if err != nil {
t.Fatalf("collect trajectories: %v", err)
}
if report.PositionRows != 1 || report.VehicleRows != 1 || report.StoredSamples != 2 || report.Reason != "raw world coordinates stored" {
t.Fatalf("unexpected collection report: %+v", report)
}
if store.ensureCalls != 1 || len(source.limits) != 2 || source.limits[0] != 777 || source.limits[1] != 777 {
t.Fatalf("collector did not use bounded source/store once: ensure=%d limits=%v", store.ensureCalls, source.limits)
}
if len(store.samples) != 2 {
t.Fatalf("expected two trajectory samples, got %+v", store.samples)
}
if store.samples[0].WorldX != 123.25 || store.samples[0].WorldY != -456.5 || store.samples[0].WorldZ == nil || *store.samples[0].WorldZ != 7.75 {
t.Fatalf("player coordinates were changed before storage: %+v", store.samples[0])
}
if store.samples[1].SubjectType != "vehicle" || store.samples[1].WorldX != -10.5 || store.samples[1].WorldY != 20.25 || store.samples[1].Source != "plugin.sql.scum.vehicles" {
t.Fatalf("vehicle coordinates were changed before storage: %+v", store.samples[1])
}
}
@@ -1,17 +0,0 @@
{
"version": 1,
"databaseUserVersion": 57,
"owner": "game.scum",
"store": "plugin-shared-platform-mysql",
"tables": [
{
"name": "scum_trajectories",
"writer": "companion.SCUMSQLStore.StoreTrajectorySamples",
"migration": "companion.SCUMSQLStore.EnsureSchema",
"primaryKey": "record_key",
"uniqueSampleKey": ["server_instance_id", "subject_type", "subject_id", "sampled_at"],
"coordinateColumns": ["world_x", "world_y", "world_z"],
"coordinatePolicy": "store-game-world-coordinates-only"
}
]
}
@@ -1,12 +1,10 @@
import type { SCUMCommandResult, SCUMConfigPatch, SCUMConfigRead, SCUMFeatureAvailability, SCUMFeatureKey, SCUMGiftGrant, SCUMPlayerProfile, SCUMStatePatch, SCUMStateSnapshot, SCUMTrajectoryCollection, SCUMVehicleSpawn } from "./contracts.js";
import type { SCUMCommandResult, SCUMConfigPatch, SCUMConfigRead, SCUMFeatureAvailability, SCUMFeatureKey, SCUMStatePatch, SCUMStateSnapshot, SCUMVehicleSpawn } from "./contracts.js";
import { validateConfigPatch, validateStatePatch, validateVehicleSpawn } from "./schemas.js";
export type PluginFeatureBridge = { dispatch(action: "game-client.command" | "game-client.snapshot.read", payload: Record<string, string>): Promise<{ status: string; result?: Record<string, string>; error?: { message: string } }> };
export type PluginFeatureBridge = { dispatch(action: "game-client.command", payload: Record<string, string>): Promise<{ status: string; result?: Record<string, string>; error?: { message: string } }> };
export type SCUMFeatureAPI = {
availability(feature: SCUMFeatureKey): Promise<SCUMFeatureAvailability>; readConfig(): Promise<SCUMConfigRead | null>; patchConfig(patch: SCUMConfigPatch): Promise<SCUMCommandResult>;
playerProfile(playerId: string): Promise<SCUMPlayerProfile | null>; stateSnapshot(playerId: string): Promise<SCUMStateSnapshot | null>; requestStatePatch(patch: SCUMStatePatch): Promise<SCUMCommandResult>;
requestVehicleSpawn(spawn: SCUMVehicleSpawn): Promise<SCUMCommandResult>;
giftGrants(): Promise<SCUMGiftGrant[]>; trajectories(): Promise<SCUMTrajectoryCollection>;
stateSnapshot(playerId: string): Promise<SCUMStateSnapshot | null>; requestStatePatch(patch: SCUMStatePatch): Promise<SCUMCommandResult>; requestVehicleSpawn(spawn: SCUMVehicleSpawn): Promise<SCUMCommandResult>;
};
export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, availableFeatures: readonly SCUMFeatureAvailability[]): SCUMFeatureAPI {
@@ -15,12 +13,9 @@ export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, availableFeatu
availability,
async readConfig() { const result = await bridge.dispatch("game-client.command", { type: "config.read" }); return result.status === "ok" ? decode<SCUMConfigRead>(result.result) : null; },
async patchConfig(patch) { const error = validateConfigPatch(patch); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "config.patch", patch: JSON.stringify(patch) })); },
async playerProfile(playerId) { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", subjectId: playerId }); return result.status === "ok" ? decode<SCUMPlayerProfile>(result.result) : null; },
async stateSnapshot(playerId) { const result = await bridge.dispatch("game-client.command", { type: "player.lookup", playerId }); return result.status === "ok" ? decode<SCUMStateSnapshot>(result.result) : null; },
async requestStatePatch(patch) { const error = validateStatePatch(patch.changes); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "game-state.patch", patch: JSON.stringify(patch) })); },
async requestVehicleSpawn(spawn) { const error = validateVehicleSpawn(spawn); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "vehicle.spawn", vehicleCode: spawn.vehicleCode })); },
async giftGrants() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", view: "gifts" }); return result.status === "ok" ? decode<SCUMGiftGrant[]>(result.result) ?? [] : []; },
async trajectories() { return { available: false, reason: "轨迹由 SCUM 插件 companion 直接写入 scum_trajectories;页面数据请读取插件表。", trajectories: [] }; }
async requestVehicleSpawn(spawn) { const error = validateVehicleSpawn(spawn); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "vehicle.spawn", vehicleCode: spawn.vehicleCode })); }
};
}
@@ -311,12 +311,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" }, "登录历史来自插件自有 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, textField(row, "lastLoginIp", "loginIp", "ipAddress", "ip") || shortHash(textField(row, "networkCorrelation")) || textField(row, "reason") || "无网络字段"))) : 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, textField(row, "lastLoginIp", "loginIp", "ipAddress", "ip") || 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.trajectories, player).filter((row) => layerOf(row) === "players" && hasCoordinates(positionOf(row))).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 120);
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹来自 SCUM 插件自有轨迹表;保存的是游戏原始 world 坐标,地图像素只在前端显示时计算。"), 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") || "plugin.sql")); }) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。")));
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹来自 Run 每 3 秒查询 SCUM.db 的采样投影;乘车状态按同一时刻附近载具保守标识。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => { const ride = nearbyVehicle(row, data.vehicles); return e("div", { key: idOf(row, `trajectory-${index}`), className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt", "createdAt")), e("strong", null, coords(positionOf(row))), e("strong", null, ride ? `疑似乘坐 ${pointTitle(ride)}` : textField(row, "source") || "run.sqlite")); }) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。")));
}
function playerRecords(rows: RecordMap[], player: RecordMap): RecordMap[] { const identities = playerIdentities(player); return rows.filter((row) => identities.includes(textField(row, "steamId", "playerId", "gamePlayerId", "userProfileId", "profileId"))); }
@@ -563,8 +563,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-world-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-world-dot map-layer-${layerOf(point)}${ride ? " map-world-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") || "plugin.sql")))) : null) : 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" }, "当前图层和筛选条件下没有真实地图点。"))
)
);
}
+159 -409
View File
@@ -3,7 +3,7 @@
"id": "game.scum",
"name": "SCUM Server",
"description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and companion bridge support.",
"version": "0.1.14",
"version": "0.1.15",
"kind": "game-plugin",
"tags": [
"scum",
@@ -205,13 +205,6 @@
"keepForSeconds": 604800,
"maxRecords": 1000
},
{
"type": "semantic.events",
"schemaVersion": "1",
"schemaRef": "schemas/bridge/semantic-events.snapshot.schema.json",
"keepForSeconds": 604800,
"maxRecords": 1000
},
{
"type": "online.sessions",
"schemaVersion": "1",
@@ -268,7 +261,21 @@
"sqlRef": "sql/scum-db-v57/users.sql",
"pollIntervalSeconds": 3,
"maxRows": 500,
"timeoutSeconds": 15
"timeoutSeconds": 15,
"projections": [
{
"collection": "scum_users",
"rowPath": "rows",
"upsertKeys": [
"steamId"
],
"fixedValues": {
"source": "run.sqlite.scum.player.profile"
},
"observedAtField": "profileSampledAt",
"mergeExisting": true
}
]
},
{
"key": "scum.squads",
@@ -310,7 +317,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 +404,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",
@@ -383,249 +511,6 @@
"timeoutSeconds": 15
}
],
"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": [
"scum.console.stdout"
],
"steps": [
{
"pattern": "Player \"(?P<displayName>[^\"]+)\" reported as player (?P<slot>\\d+)"
},
{
"pattern": "Player (?P<slot>\\d+) SteamID \\(assumed\\): (?P<steamId>\\d+)"
}
],
"correlationFields": [
"slot"
],
"maxInterveningLines": 8,
"target": {
"collection": "scum_users",
"upsertKeys": [
"steamId"
],
"captureMappings": {
"steamId": "steamId",
"displayName": "displayName",
"slot": "slot"
},
"fixedValues": {
"online": "true",
"source": "process.stdout"
},
"observedAtField": "lastLoginObservedAt"
},
"presence": {
"timestampField": "lastLoginObservedAt",
"activeWindowSeconds": 600,
"activityTarget": {
"collection": "scum_activity_events",
"upsertKeys": [
"steamId",
"observedAt"
],
"captureMappings": {
"steamId": "steamId",
"displayName": "displayName"
},
"fixedValues": {
"eventType": "login",
"source": "process.stdout"
},
"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",
"lastLoginIp": "ip"
},
"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",
"lastLoginIp": "ip"
},
"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",
@@ -745,16 +630,12 @@
{
"key": "scum-db-v57",
"databaseUserVersion": 57,
"logParserRefs": [
"data-packs/scum-db-v57/log-parsers.json"
],
"configMapRefs": [
"data-packs/scum-db-v57/config-maps.json"
],
"dataRefs": [
"data-packs/scum-db-v57/gift-items.json",
"data-packs/scum-db-v57/map-geometry.json",
"data-packs/scum-db-v57/storage-model.json"
"data-packs/scum-db-v57/map-geometry.json"
]
}
],
@@ -767,9 +648,6 @@
"permission": "server.game-client.read",
"requiredHandlers": [
"player.lookup"
],
"requiredEventProducers": [
"semantic.events"
]
},
{
@@ -792,10 +670,7 @@
{
"key": "trajectory.collect",
"title": "SCUM trajectories",
"permission": "server.game-client.read",
"requiredEventProducers": [
"semantic.events"
]
"permission": "server.game-client.read"
}
],
"pages": [
@@ -1196,10 +1071,6 @@
"path": "data-packs/scum-db-v57/config-maps.json",
"mode": 384
},
{
"path": "data-packs/scum-db-v57/log-parsers.json",
"mode": 384
},
{
"path": "data-packs/scum-db-v57/gift-items.json",
"mode": 384
@@ -1207,10 +1078,6 @@
{
"path": "data-packs/scum-db-v57/map-geometry.json",
"mode": 384
},
{
"path": "data-packs/scum-db-v57/storage-model.json",
"mode": 384
}
],
"productionLifecycle": {
@@ -1379,6 +1246,7 @@
},
"transportKeys": [
"server-files",
"scum-database",
"scum-management"
],
"dllExtensionRefs": [
@@ -1538,138 +1406,6 @@
"retentionDays": 30
}
],
"logEvents": [
{
"key": "scum-player-position",
"title": "SCUM player position",
"sourceKey": "scum-client-events",
"eventType": "player.position",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/player-position.event.schema.json",
"retentionDays": 7,
"severity": "info"
},
{
"key": "scum-vehicle-position",
"title": "SCUM vehicle position",
"sourceKey": "scum-client-events",
"eventType": "vehicle.position",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/vehicle-position.event.schema.json",
"retentionDays": 7,
"severity": "info"
},
{
"key": "scum-player-vehicle-enter",
"title": "SCUM player vehicle enter",
"sourceKey": "scum-client-events",
"eventType": "player.vehicle.enter",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/player-vehicle-enter.event.schema.json",
"retentionDays": 7,
"severity": "info"
},
{
"key": "scum-player-vehicle-leave",
"title": "SCUM player vehicle leave",
"sourceKey": "scum-client-events",
"eventType": "player.vehicle.leave",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/player-vehicle-leave.event.schema.json",
"retentionDays": 7,
"severity": "info"
},
{
"key": "scum-chat",
"title": "SCUM chat message",
"sourceKey": "scum-chat-events",
"eventType": "scum.chat",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/chat.event.schema.json",
"retentionDays": 90,
"severity": "info"
},
{
"key": "scum-login",
"title": "SCUM player login",
"sourceKey": "scum-login-events",
"eventType": "scum.login",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/login.event.schema.json",
"retentionDays": 90,
"severity": "info"
},
{
"key": "scum-logout",
"title": "SCUM player logout",
"sourceKey": "scum-login-events",
"eventType": "scum.logout",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/logout.event.schema.json",
"retentionDays": 90,
"severity": "info"
},
{
"key": "scum-kill",
"title": "SCUM player kill",
"sourceKey": "scum-kill-events",
"eventType": "scum.kill",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/kill.event.schema.json",
"retentionDays": 90,
"severity": "info"
},
{
"key": "scum-trade",
"title": "SCUM trade activity",
"sourceKey": "scum-trade-events",
"eventType": "scum.trade",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/trade.event.schema.json",
"retentionDays": 90,
"severity": "info"
},
{
"key": "scum-mine",
"title": "SCUM mine activity",
"sourceKey": "scum-server-events",
"eventType": "scum.mine",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/mine.event.schema.json",
"retentionDays": 90,
"severity": "warning"
},
{
"key": "scum-unlock",
"title": "SCUM unlock activity",
"sourceKey": "scum-server-events",
"eventType": "scum.unlock",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/unlock.event.schema.json",
"retentionDays": 90,
"severity": "warning"
},
{
"key": "scum-admin",
"title": "SCUM admin activity",
"sourceKey": "scum-admin-events",
"eventType": "scum.admin",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/admin.event.schema.json",
"retentionDays": 90,
"severity": "warning"
},
{
"key": "scum-performance",
"title": "SCUM server performance",
"sourceKey": "scum-performance-events",
"eventType": "scum.performance",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/performance.event.schema.json",
"retentionDays": 30,
"severity": "info"
}
],
"transportProfiles": [
{
"key": "server-files",
@@ -1724,6 +1460,21 @@
]
}
],
"dataTargets": [
{
"key": "scum-database",
"kind": "sqlite.snapshot",
"transportKey": "scum-database",
"sourceRootKey": "server-root",
"sourcePath": "SCUM/Saved/SaveFiles/SCUM.db",
"workspaceKey": "databases/scum-database",
"refreshPolicy": "on-demand-snapshot",
"maxBytes": 1073741824,
"platforms": [
"windows"
]
}
],
"dllExtensions": [
{
"key": "scum-simple-rcon",
@@ -1756,7 +1507,7 @@
"displayName": "SCUM Client Manager",
"version": "1.0.0",
"repository": {
"url": "https://git.npc0.com/admin343/browser.git",
"url": "https://github.com/F88888/scum_client.git",
"revisionPolicy": "branch",
"branch": "main"
},
@@ -1768,8 +1519,7 @@
],
"build": {
"system": "go",
"workspaceRef": "plugins/examples/scum-server-plugin/companion",
"entryRef": "cmd/scum-companion"
"entryRef": "main.go"
},
"configTemplates": [
{
@@ -2,7 +2,7 @@
These query template keys are browser-safe declarations. They intentionally do not carry SQL text, host paths, DSNs, sockets, or credentials. The bound run/agent beside the current SCUM service owns the actual SQLite read implementation and must return rows matching the referenced result schemas.
| Template key | SCUM.db source tables | Plugin-owned output |
| Template key | SCUM.db source tables | Projection target |
| --- | --- | --- |
| `scum.player.profile` | `user_profile`, `prisoner`, `prisoner_entity`, `entity`, `bank_account_registry`, `bank_account_registry_currencies`, optional `squad_member` / `squad` joins | Player identity, economy, squad summary, and current position |
| `scum.squads` | `squad`, optional `squad_member`, optional `user_profile` leader joins | Squad records and leader/member counts |
@@ -38,13 +38,5 @@
},
"tls": {
"policy": "verify-system-roots"
},
"trajectory": {
"enabled": true,
"source": "scum-sqlite",
"store": "shared-platform-mysql",
"fileEnv": "SCUM_DB_FILE",
"intervalSeconds": 3,
"maxRows": 500
}
}
@@ -86,19 +86,6 @@
"properties": {
"policy": { "const": "verify-system-roots" }
}
},
"trajectory": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "source", "store", "fileEnv", "intervalSeconds", "maxRows"],
"properties": {
"enabled": { "type": "boolean" },
"source": { "const": "scum-sqlite" },
"store": { "const": "shared-platform-mysql" },
"fileEnv": { "const": "SCUM_DB_FILE" },
"intervalSeconds": { "type": "integer", "minimum": 1, "maximum": 3600 },
"maxRows": { "type": "integer", "minimum": 1, "maximum": 5000 }
}
}
},
"$defs": {
@@ -1 +0,0 @@
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","collectedAt","source","mapId","mapVersion","playerId","worldX","worldY"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"collectedAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","plugin.sql"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"worldX":{"type":"number","minimum":-500000,"maximum":500000},"worldY":{"type":"number","minimum":-500000,"maximum":500000}}}
@@ -1 +0,0 @@
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","source","mapId","mapVersion","playerId","vehicleId"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","plugin.sql"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96}}}
@@ -1 +0,0 @@
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","source","mapId","mapVersion","playerId","vehicleId"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","plugin.sql"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"playerId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96}}}
@@ -1 +0,0 @@
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","additionalProperties":false,"required":["occurredAt","collectedAt","source","mapId","mapVersion","vehicleId","worldX","worldY"],"properties":{"occurredAt":{"type":"string","format":"date-time","maxLength":40},"collectedAt":{"type":"string","format":"date-time","maxLength":40},"source":{"enum":["companion","plugin.sql"]},"mapId":{"const":"scum-island"},"mapVersion":{"type":"string","maxLength":80},"vehicleId":{"type":"string","pattern":"^[A-Za-z0-9_.:-]{1,96}$","maxLength":96},"worldX":{"type":"number","minimum":-500000,"maximum":500000},"worldY":{"type":"number","minimum":-500000,"maximum":500000}}}