Make SCUM logs live relay only

This commit is contained in:
npc0-hue
2026-09-01 14:26:00 +08:00
parent 3f348401a9
commit 7d9c1b7e9e
28 changed files with 1011 additions and 175 deletions
@@ -30,6 +30,8 @@ func main() {
trajectoryStatus := &companion.TrajectoryCollectionStatus{}
collector, cleanup := buildTrajectoryCollector(config, trajectoryStatus)
defer cleanup()
consoleCollector, consoleCleanup := buildConsoleLogCollector(config, client)
defer consoleCleanup()
registry := companion.NewHandlerRegistry(defaultHandlerAvailability(config), companion.RuntimeAdapter{
BoundServerID: config.Component.ServerInstanceID,
@@ -52,11 +54,14 @@ func main() {
Health: trajectoryStatus.HealthReport,
}
errorsCh := make(chan error, 2)
errorsCh := make(chan error, 3)
go func() { errorsCh <- runtime.Run(ctx) }()
if collector != nil {
go func() { errorsCh <- collector.Run(ctx, trajectoryStatus) }()
}
if consoleCollector != nil {
go func() { errorsCh <- consoleCollector.Run(ctx) }()
}
err = <-errorsCh
stop()
@@ -75,6 +80,18 @@ func loadConfig() (companion.Config, error) {
return companion.LoadConfig(file)
}
func buildConsoleLogCollector(config companion.Config, client *companion.Client) (*companion.ConsoleLogCollector, func()) {
cleanup := func() {}
store, err := companion.OpenSCUMSQLStoreFromEnv(companion.PlatformMySQLDSNEnvironment)
if err != nil {
log.Printf("SCUM companion console log store unavailable: %v", err)
return nil, cleanup
}
cleanup = func() { _ = store.Close() }
secret := os.Getenv(config.Proof.MaterialEnv)
return companion.NewConsoleLogCollector(client, store, config.Component.ServerInstanceID, secret), cleanup
}
func buildTrajectoryCollector(config companion.Config, status *companion.TrajectoryCollectionStatus) (*companion.TrajectoryCollector, func()) {
cleanup := func() {}
if !config.Trajectory.Enabled {
@@ -0,0 +1,100 @@
package companion
import (
"context"
"fmt"
"strings"
"time"
)
type ConsoleLogStreamClient interface {
StreamLogEvents(context.Context, func(LogStreamEvent) error) error
}
type ConsoleLogStore interface {
EnsureSchema(context.Context) error
StoreConsoleRecords(context.Context, []ConsoleRecord) (int, error)
StoreSemanticEventBatch(context.Context, SemanticEventBatch) (int, error)
}
type ConsoleLogCollector struct {
Client ConsoleLogStreamClient
Store ConsoleLogStore
ServerInstanceID string
CorrelationSecret string
Backoff time.Duration
}
func NewConsoleLogCollector(client ConsoleLogStreamClient, store ConsoleLogStore, serverInstanceID string, correlationSecret string) *ConsoleLogCollector {
return &ConsoleLogCollector{Client: client, Store: store, ServerInstanceID: serverInstanceID, CorrelationSecret: correlationSecret, Backoff: 2 * time.Second}
}
func (collector *ConsoleLogCollector) Run(ctx context.Context) error {
if collector == nil || collector.Client == nil || collector.Store == nil || strings.TrimSpace(collector.ServerInstanceID) == "" {
return fmt.Errorf("console log collector is not configured")
}
if err := collector.Store.EnsureSchema(ctx); err != nil {
return err
}
backoff := collector.Backoff
if backoff <= 0 {
backoff = 2 * time.Second
}
for {
err := collector.Client.StreamLogEvents(ctx, collector.handleEvent(ctx))
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
}
continue
}
}
}
func (collector *ConsoleLogCollector) handleEvent(ctx context.Context) func(LogStreamEvent) error {
return func(event LogStreamEvent) error {
record, ok := consoleRecordFromLogEvent(collector.ServerInstanceID, event)
if !ok {
return nil
}
if _, err := collector.Store.StoreConsoleRecords(ctx, []ConsoleRecord{record}); err != nil {
return err
}
batch := ParseConsoleRecords(collector.ServerInstanceID, []ConsoleRecord{record}, collector.CorrelationSecret)
if _, err := collector.Store.StoreSemanticEventBatch(ctx, batch); err != nil {
return err
}
return nil
}
}
func consoleRecordFromLogEvent(serverInstanceID string, event LogStreamEvent) (ConsoleRecord, bool) {
if event.ServerInstanceID != serverInstanceID || event.Entry.Seq == 0 || strings.TrimSpace(event.Entry.Line) == "" {
return ConsoleRecord{}, false
}
stream := consoleStreamName(event.StreamKey)
if stream == "" {
return ConsoleRecord{}, false
}
occurredAt := event.Entry.Timestamp
if occurredAt.IsZero() {
occurredAt = time.Now().UTC()
}
return ConsoleRecord{ServerID: serverInstanceID, Stream: stream, Sequence: event.Entry.Seq, OccurredAt: occurredAt.UTC(), Text: event.Entry.Line}, true
}
func consoleStreamName(streamKey string) string {
key := strings.ToLower(strings.TrimSpace(streamKey))
if strings.Contains(key, "stderr") || strings.HasSuffix(key, ".err") || strings.HasSuffix(key, "-err") {
return "stderr"
}
if strings.Contains(key, "stdout") || strings.Contains(key, "console") || strings.HasSuffix(key, ".out") || strings.HasSuffix(key, "-out") {
return "stdout"
}
return ""
}
@@ -0,0 +1,62 @@
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)
}
}
@@ -0,0 +1,127 @@
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)`
@@ -0,0 +1,125 @@
package companion
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const logEventsPath = "/api/v1/game-client-bridge/companion/logs/events"
type LogEntry struct {
Seq uint64 `json:"seq"`
Timestamp time.Time `json:"timestamp"`
Level string `json:"level,omitempty"`
Line string `json:"line"`
Fields map[string]string `json:"fields,omitempty"`
Redacted bool `json:"redacted"`
}
type LogStreamEvent struct {
ServerInstanceID string `json:"serverInstanceId"`
StreamID string `json:"streamId"`
Source string `json:"source"`
StreamKey string `json:"streamKey"`
LogSessionID string `json:"logSessionId,omitempty"`
SessionStartedAt time.Time `json:"sessionStartedAt,omitempty"`
LatestSeq uint64 `json:"latestSeq"`
Entry LogEntry `json:"entry"`
}
type logStreamRequest struct {
SessionToken string `json:"sessionToken"`
}
func (client *Client) StreamLogEvents(ctx context.Context, handle func(LogStreamEvent) error) error {
if handle == nil {
return fmt.Errorf("log event handler is required")
}
token, err := client.currentSession()
if err != nil {
return err
}
encoded, err := json.Marshal(logStreamRequest{SessionToken: token})
if err != nil {
return fmt.Errorf("encode log stream request: %w", err)
}
if len(encoded) > maxRequestBytes {
return fmt.Errorf("platform request exceeds the bounded payload size")
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.config.Platform.BaseURL+logEventsPath, bytes.NewReader(encoded))
if err != nil {
return fmt.Errorf("create log stream request: %w", err)
}
request.Header.Set("Accept", "text/event-stream")
request.Header.Set("Content-Type", "application/json")
response, err := client.httpClient.Do(request)
if err != nil {
return fmt.Errorf("open log stream: %w", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
return HTTPError{StatusCode: response.StatusCode, ExpectedStatus: http.StatusOK}
}
return readLogEventStream(ctx, response.Body, handle)
}
func readLogEventStream(ctx context.Context, body io.Reader, handle func(LogStreamEvent) error) error {
reader := bufio.NewReader(body)
var eventName string
var dataLines []string
flush := func() error {
if len(dataLines) == 0 {
eventName = ""
return nil
}
name := eventName
if name == "" {
name = "message"
}
payload := strings.Join(dataLines, "\n")
eventName = ""
dataLines = nil
if name != "log" {
return nil
}
var event LogStreamEvent
if err := json.Unmarshal([]byte(payload), &event); err != nil {
return fmt.Errorf("decode log event: %w", err)
}
return handle(event)
}
for {
line, err := reader.ReadString('\n')
if len(line) > 0 {
line = strings.TrimRight(line, "\r\n")
switch {
case line == "":
if flushErr := flush(); flushErr != nil {
return flushErr
}
case strings.HasPrefix(line, ":"):
case strings.HasPrefix(line, "event:"):
eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
if err != nil {
if ctx != nil && ctx.Err() != nil {
return ctx.Err()
}
if err == io.EOF {
return flush()
}
return fmt.Errorf("read log stream: %w", err)
}
}
}
@@ -0,0 +1,66 @@
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)
}
}
@@ -348,6 +348,33 @@ CREATE TABLE IF NOT EXISTS scum_trajectories (
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 = `
@@ -102,6 +102,61 @@ func TestSCUMSQLStoreWritesTrajectorySamplesWithoutCoordinateConversion(t *testi
}
}
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{
@@ -168,3 +223,16 @@ func findStatement(statements []string, prefix string) int {
}
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
}
}