401 lines
13 KiB
Go
401 lines
13 KiB
Go
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)`
|