Move SCUM trajectories to plugin SQL storage
This commit is contained in:
@@ -199,7 +199,6 @@ 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*\(`),
|
||||
|
||||
@@ -10,8 +10,9 @@ import (
|
||||
)
|
||||
|
||||
// SafeAdapter is intentionally narrow: it receives typed values only and has
|
||||
// no direct transport, host-path, credential, or shell access. Protected SQL,
|
||||
// RCON, and management-program text is forwarded to Run by Platform, not here.
|
||||
// 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.
|
||||
type SafeAdapter interface {
|
||||
ReadConfiguration(context.Context) (map[string]any, error)
|
||||
PatchConfiguration(context.Context, map[string]any) (map[string]any, error)
|
||||
|
||||
@@ -2,4 +2,8 @@ module browser.local/plugins/scum-server-plugin/companion
|
||||
|
||||
go 1.25.1
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
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=
|
||||
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=
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
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 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`}
|
||||
|
||||
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)`
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"databaseUserVersion": 57,
|
||||
"owner": "game.scum",
|
||||
"store": "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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,8 +19,8 @@ export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, availableFeatu
|
||||
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", projection: "gifts" }); return result.status === "ok" ? decode<SCUMGiftGrant[]>(result.result) ?? [] : []; },
|
||||
async trajectories() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", projection: "trajectories" }); return result.status === "ok" ? decode<SCUMTrajectoryCollection>(result.result) ?? { available: false, reason: "没有已验证的位置事件源。", trajectories: [] } : { available: false, reason: result.error?.message ?? "没有已验证的位置事件源。", trajectories: [] }; }
|
||||
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() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", view: "trajectories" }); return result.status === "ok" ? decode<SCUMTrajectoryCollection>(result.result) ?? { available: false, reason: "没有已验证的位置事件源。", trajectories: [] } : { available: false, reason: result.error?.message ?? "没有已验证的位置事件源。", trajectories: [] }; }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }, "用户轨迹来自 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" }, "没有该用户的真实轨迹记录。")));
|
||||
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" }, "没有该用户的真实轨迹记录。")));
|
||||
}
|
||||
|
||||
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-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" }, "当前图层和筛选条件下没有真实地图点。"))
|
||||
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" }, "当前图层和筛选条件下没有真实地图点。"))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,21 +268,7 @@
|
||||
"sqlRef": "sql/scum-db-v57/users.sql",
|
||||
"pollIntervalSeconds": 3,
|
||||
"maxRows": 500,
|
||||
"timeoutSeconds": 15,
|
||||
"projections": [
|
||||
{
|
||||
"collection": "scum_users",
|
||||
"rowPath": "rows",
|
||||
"upsertKeys": [
|
||||
"steamId"
|
||||
],
|
||||
"fixedValues": {
|
||||
"source": "run.sqlite.scum.player.profile"
|
||||
},
|
||||
"observedAtField": "profileSampledAt",
|
||||
"mergeExisting": true
|
||||
}
|
||||
]
|
||||
"timeoutSeconds": 15
|
||||
},
|
||||
{
|
||||
"key": "scum.squads",
|
||||
@@ -324,66 +310,7 @@
|
||||
"sqlRef": "sql/scum-db-v57/vehicles.sql",
|
||||
"pollIntervalSeconds": 3,
|
||||
"maxRows": 500,
|
||||
"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
|
||||
}
|
||||
]
|
||||
"timeoutSeconds": 15
|
||||
},
|
||||
{
|
||||
"key": "scum.flags",
|
||||
@@ -411,69 +338,7 @@
|
||||
"sqlRef": "sql/scum-db-v57/map-points.sql",
|
||||
"pollIntervalSeconds": 3,
|
||||
"maxRows": 500,
|
||||
"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"
|
||||
}
|
||||
]
|
||||
"timeoutSeconds": 15
|
||||
},
|
||||
{
|
||||
"key": "scum.tasks",
|
||||
@@ -888,7 +753,8 @@
|
||||
],
|
||||
"dataRefs": [
|
||||
"data-packs/scum-db-v57/gift-items.json",
|
||||
"data-packs/scum-db-v57/map-geometry.json"
|
||||
"data-packs/scum-db-v57/map-geometry.json",
|
||||
"data-packs/scum-db-v57/storage-model.json"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -1341,6 +1207,10 @@
|
||||
{
|
||||
"path": "data-packs/scum-db-v57/map-geometry.json",
|
||||
"mode": 384
|
||||
},
|
||||
{
|
||||
"path": "data-packs/scum-db-v57/storage-model.json",
|
||||
"mode": 384
|
||||
}
|
||||
],
|
||||
"productionLifecycle": {
|
||||
|
||||
@@ -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 | Projection target |
|
||||
| Template key | SCUM.db source tables | Plugin-owned output |
|
||||
| --- | --- | --- |
|
||||
| `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 |
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"$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","log-projection"]},"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}}}
|
||||
{"$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
@@ -1 +1 @@
|
||||
{"$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","log-projection"]},"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}}}
|
||||
{"$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
@@ -1 +1 @@
|
||||
{"$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","log-projection"]},"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}}}
|
||||
{"$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
@@ -1 +1 @@
|
||||
{"$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","log-projection"]},"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}}}
|
||||
{"$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}}}
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ export const scumMigrationParityFixtures = {
|
||||
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "patch-1", recordedAt: "2026-07-29T00:30:00Z", payload: { id: "patch-1", playerId: "player-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] } }
|
||||
},
|
||||
trajectory: {
|
||||
source: { id: "trajectory-1", updatedAt: "2026-07-29T00:40:00Z", kind: "player", entityId: "steam-1", gamePlayerRecordId: "player-1", points: [{ mapX: 10, mapY: 20, occurredAt: "2026-07-29T00:39:00Z", source: "log-projection" }, { mapX: 30, mapY: 40, occurredAt: "not-a-timestamp" }] },
|
||||
source: { id: "trajectory-1", updatedAt: "2026-07-29T00:40:00Z", kind: "player", entityId: "steam-1", gamePlayerRecordId: "player-1", points: [{ mapX: 10, mapY: 20, occurredAt: "2026-07-29T00:39:00Z", source: "legacy-log" }, { mapX: 30, mapY: 40, occurredAt: "not-a-timestamp" }] },
|
||||
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "trajectory-1", recordedAt: "2026-07-29T00:40:00Z", payload: { subjectId: "player-1", subjectType: "player", provenance: "transitional-read-only", points: [{ occurredAt: "2026-07-29T00:39:00Z", subjectId: "player-1", subjectType: "player", x: 10, y: 20, source: "transitional-read-only" }] } }
|
||||
}
|
||||
} as const;
|
||||
|
||||
@@ -501,9 +501,7 @@ describe("plugin manifest validation", () => {
|
||||
"maintenance.prepare"
|
||||
]));
|
||||
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]));
|
||||
expect(manifest.gameClientBridge.queryTemplates.find((template) => template.key === "scum.vehicles")?.projections).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ collection: "scum_trade_goods", mergeExisting: true, fixedValues: expect.objectContaining({ catalogType: "vehicle", type: "21", typeName: "其他载具" }) })
|
||||
]));
|
||||
expect(manifest.gameClientBridge.queryTemplates.every((template) => !template.projections?.length)).toBe(true);
|
||||
expect(manifest.gameClientBridge.logProjections?.map((projection) => projection.key)).toEqual(expect.arrayContaining(["scum.trade.catalog", "scum.trade.events"]));
|
||||
expect(manifest.gameClientBridge.logProjections?.find((projection) => projection.key === "scum.trade.catalog")).toMatchObject({ streamKeys: ["scum.trade"], target: { collection: "scum_trade_goods", upsertKeys: ["code"], captureMappings: { code: "itemCode" } } });
|
||||
expect(manifest.gameClientBridge.logProjections?.find((projection) => projection.key === "scum.trade.events")?.target?.collection).toBe("scum_trade_events");
|
||||
@@ -595,7 +593,7 @@ describe("plugin manifest validation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("declares bounded SCUM snapshot schemas for operations projections", () => {
|
||||
it("declares bounded SCUM snapshot schemas for operations views", () => {
|
||||
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
||||
gameClientBridge: {
|
||||
@@ -673,7 +671,7 @@ describe("plugin manifest validation", () => {
|
||||
const fastTemplates = new Set(["scum.player.profile", "scum.vehicles", "scum.positions"]);
|
||||
const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
|
||||
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
|
||||
expect(templatesByKey.get("scum.player.profile")?.projections).toEqual([expect.objectContaining({ collection: "scum_users", rowPath: "rows", upsertKeys: ["steamId"], observedAtField: "profileSampledAt", mergeExisting: true })]);
|
||||
expect([...templatesByKey.values()].every((template) => !template.projections?.length)).toBe(true);
|
||||
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query");
|
||||
expect(manifest.capabilities).toContain("remote.run.db.sqlite.execute");
|
||||
expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query");
|
||||
@@ -741,11 +739,13 @@ describe("plugin manifest validation", () => {
|
||||
const configMaps = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.configMapRefs[0]), "utf8"));
|
||||
const giftMetadata = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![0]), "utf8"));
|
||||
const mapGeometry = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![1]), "utf8"));
|
||||
const storageModel = JSON.parse(fs.readFileSync(path.join(pluginDir, pack!.dataRefs![2]), "utf8"));
|
||||
expect(logParsers).toMatchObject({ encoding: "utf-16le", lineEnding: "lf", continuationPolicy: "append-to-previous-timestamped-record", timestampFormat: "yyyy.MM.dd-HH.mm.ss" });
|
||||
expect(logParsers.parsers.map((parser: { key: string }) => parser.key)).toEqual(expect.arrayContaining(["login", "chat", "admin", "kill", "event-kill", "quests", "vehicle-destruction"]));
|
||||
expect(configMaps.maps.map((map: { key: string }) => map.key)).toEqual(expect.arrayContaining(["server-settings", "economy-override", "raid-times", "notifications", "admin-users", "banned-users"]));
|
||||
expect(giftMetadata).toMatchObject({ databaseUserVersion: 57, catalogSource: { configMapKey: "economy-override" } });
|
||||
expect(mapGeometry).toMatchObject({ databaseUserVersion: 57, image: { path: "assets/map/scum-map-overview.jpg", width: 256, height: 256 }, runtimeOverride: { kilometersToWorldUnits: 100000 } });
|
||||
expect(storageModel).toMatchObject({ databaseUserVersion: 57, store: "platform-mysql", tables: [expect.objectContaining({ name: "scum_trajectories", writer: "companion.SCUMSQLStore.StoreTrajectorySamples", coordinateColumns: ["world_x", "world_y", "world_z"], coordinatePolicy: "store-game-world-coordinates-only" })] });
|
||||
});
|
||||
|
||||
it("declares typed SCUM semantic log events with bounded schemas", () => {
|
||||
|
||||
@@ -39,8 +39,8 @@ const surfaceData: SCUMSurfaceData = {
|
||||
mapSettings: [],
|
||||
vehicles: [{ vehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", position: { x: 400, y: 200, z: 0 }, freshness: { status: "fresh" } }],
|
||||
trajectories: [
|
||||
{ subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, sampledAt: "2026-08-10T00:00:03Z", source: "run.sqlite.scum.positions" },
|
||||
{ subjectType: "vehicle", subjectId: "veh-1", vehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", x: 400, y: 200, z: 0, sampledAt: "2026-08-10T00:00:03Z", source: "run.sqlite.scum.vehicles" }
|
||||
{ subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, sampledAt: "2026-08-10T00:00:03Z", source: "plugin.sql.scum.positions" },
|
||||
{ subjectType: "vehicle", subjectId: "veh-1", vehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", x: 400, y: 200, z: 0, sampledAt: "2026-08-10T00:00:03Z", source: "plugin.sql.scum.vehicles" }
|
||||
],
|
||||
flags: [{ flagId: "flag-1", name: "Wolves Flag", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { x: 100, y: 80, z: 0 }, freshness: { status: "fresh" } }]
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user