Restore durable log ingest and typed plugin projections
This commit is contained in:
@@ -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" }, "当前图层和筛选条件下没有真实地图点。"))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}}}
|
||||
Reference in New Issue
Block a user