Move SCUM trajectories to plugin SQL storage

This commit is contained in:
npc0-hue
2026-08-28 17:50:45 +08:00
parent bb187f6007
commit aafbec4c96
21 changed files with 611 additions and 194 deletions
@@ -0,0 +1,170 @@
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 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
}