Fix SCUM companion trajectory storage
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var openSQLite = sql.Open
|
||||
|
||||
type SCUMSQLiteSource struct{ db *sql.DB }
|
||||
|
||||
func NewSCUMSQLiteSource(db *sql.DB) (*SCUMSQLiteSource, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("SCUM database source is required")
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
return &SCUMSQLiteSource{db: db}, nil
|
||||
}
|
||||
|
||||
func OpenSCUMSQLiteSourceFromEnv(envName string) (*SCUMSQLiteSource, error) {
|
||||
name := strings.TrimSpace(envName)
|
||||
if name == "" {
|
||||
name = SCUMDatabaseFileEnvironment
|
||||
}
|
||||
databaseFile := strings.TrimSpace(os.Getenv(name))
|
||||
if databaseFile == "" {
|
||||
return nil, fmt.Errorf("%s is required for SCUM database collection", name)
|
||||
}
|
||||
info, err := os.Stat(databaseFile)
|
||||
if err != nil || info.IsDir() {
|
||||
return nil, fmt.Errorf("%s must reference a readable SCUM database file", name)
|
||||
}
|
||||
db, err := openSQLite("sqlite", databaseFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open SCUM database source: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA query_only = ON"); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("prepare SCUM database source for read-only collection: %w", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("prepare SCUM database source timeout: %w", err)
|
||||
}
|
||||
return NewSCUMSQLiteSource(db)
|
||||
}
|
||||
|
||||
func (source *SCUMSQLiteSource) Close() error {
|
||||
if source == nil || source.db == nil {
|
||||
return nil
|
||||
}
|
||||
return source.db.Close()
|
||||
}
|
||||
|
||||
func (source *SCUMSQLiteSource) ReadPositionRows(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||
return source.readRows(ctx, scumPositionRowsSQL, limit,
|
||||
sql.Named("subjectType", nil),
|
||||
sql.Named("subjectId", nil),
|
||||
sql.Named("limit", boundedTrajectoryLimit(limit)),
|
||||
)
|
||||
}
|
||||
|
||||
func (source *SCUMSQLiteSource) ReadVehicleRows(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||
return source.readRows(ctx, scumVehicleRowsSQL, limit,
|
||||
sql.Named("vehicleId", nil),
|
||||
sql.Named("search", nil),
|
||||
sql.Named("limit", boundedTrajectoryLimit(limit)),
|
||||
)
|
||||
}
|
||||
|
||||
func (source *SCUMSQLiteSource) readRows(ctx context.Context, query string, limit int, args ...any) ([]map[string]any, error) {
|
||||
if source == nil || source.db == nil {
|
||||
return nil, fmt.Errorf("SCUM database source is not configured")
|
||||
}
|
||||
rows, err := source.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read SCUM database rows: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read SCUM database columns: %w", err)
|
||||
}
|
||||
maxRows := boundedTrajectoryLimit(limit)
|
||||
result := make([]map[string]any, 0, maxRows)
|
||||
values := make([]any, len(columns))
|
||||
scanTargets := make([]any, len(columns))
|
||||
for index := range values {
|
||||
scanTargets[index] = &values[index]
|
||||
}
|
||||
for rows.Next() {
|
||||
if len(result) >= maxRows {
|
||||
break
|
||||
}
|
||||
if err := rows.Scan(scanTargets...); err != nil {
|
||||
return nil, fmt.Errorf("scan SCUM database rows: %w", err)
|
||||
}
|
||||
row := make(map[string]any, len(columns))
|
||||
for index, column := range columns {
|
||||
row[column] = normalizeSQLiteValue(values[index])
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read SCUM database rows: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func boundedTrajectoryLimit(limit int) int {
|
||||
if limit <= 0 {
|
||||
return DefaultTrajectoryCollectionMaxRows
|
||||
}
|
||||
if limit > 5000 {
|
||||
return 5000
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func normalizeSQLiteValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case []byte:
|
||||
return string(typed)
|
||||
case time.Time:
|
||||
return typed.UTC().Format(time.RFC3339Nano)
|
||||
default:
|
||||
return typed
|
||||
}
|
||||
}
|
||||
|
||||
const scumPositionRowsSQL = `SELECT
|
||||
'player' AS subjectType,
|
||||
account.id AS subjectId,
|
||||
CAST(profile.id AS TEXT) AS userProfileId,
|
||||
CAST(prisoner.id AS TEXT) AS gamePlayerId,
|
||||
NULL AS vehicleId,
|
||||
CAST(entity.id AS TEXT) AS entityId,
|
||||
NULL AS baseId,
|
||||
entity.location_x AS x,
|
||||
entity.location_y AS y,
|
||||
entity.location_z AS z,
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', prisoner.last_save_time, 'unixepoch') AS observedAt
|
||||
FROM user_profile profile
|
||||
JOIN user account ON account.id = profile.user_id
|
||||
JOIN prisoner ON prisoner.id = profile.prisoner_id
|
||||
JOIN prisoner_entity ON prisoner_entity.prisoner_id = prisoner.id
|
||||
JOIN entity ON entity.id = prisoner_entity.entity_id
|
||||
WHERE (:subjectType IS NULL OR :subjectType = 'player')
|
||||
AND (:subjectId IS NULL OR account.id = :subjectId)
|
||||
UNION ALL
|
||||
SELECT
|
||||
'vehicle', CAST(spawner.vehicle_entity_id AS TEXT), NULL, NULL,
|
||||
CAST(spawner.vehicle_entity_id AS TEXT), CAST(entity.id AS TEXT), NULL,
|
||||
entity.location_x, entity.location_y, entity.location_z,
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch')
|
||||
FROM vehicle_spawner spawner
|
||||
JOIN entity ON entity.id = spawner.vehicle_entity_id
|
||||
WHERE (:subjectType IS NULL OR :subjectType = 'vehicle')
|
||||
AND (:subjectId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :subjectId)
|
||||
LIMIT COALESCE(:limit, 500)`
|
||||
|
||||
const scumVehicleRowsSQL = `SELECT
|
||||
CAST(spawner.vehicle_entity_id AS TEXT) AS vehicleId,
|
||||
CAST(spawner.vehicle_entity_id AS TEXT) AS entityId,
|
||||
entity.class AS className,
|
||||
spawner.vehicle_alias AS label,
|
||||
entity.location_x AS x,
|
||||
entity.location_y AS y,
|
||||
entity.location_z AS z,
|
||||
strftime('%Y-%m-%dT%H:%M:%SZ', spawner.vehicle_last_access_time, 'unixepoch') AS lastAccessTime,
|
||||
spawner.is_vehicle_functional AS isFunctional
|
||||
FROM vehicle_spawner spawner
|
||||
JOIN entity ON entity.id = spawner.vehicle_entity_id
|
||||
WHERE (:vehicleId IS NULL OR CAST(spawner.vehicle_entity_id AS TEXT) = :vehicleId)
|
||||
AND (:search IS NULL OR spawner.vehicle_alias LIKE '%' || :search || '%' OR entity.class LIKE '%' || :search || '%')
|
||||
ORDER BY spawner.vehicle_last_access_time DESC
|
||||
LIMIT COALESCE(:limit, 500)`
|
||||
Reference in New Issue
Block a user