feat: surface SCUM vehicle functional state

This commit is contained in:
npc0-hue
2026-09-18 16:56:36 +08:00
parent 156392df1e
commit 60d479db71
13 changed files with 62 additions and 17 deletions
+4
View File
@@ -83,6 +83,7 @@ type SCUMVehicle struct {
DisplayName string
Exists bool
Locked bool
Functional *bool
X *float64
Y *float64
Z *float64
@@ -178,6 +179,7 @@ type SCUMVehicleFact struct {
DisplayName string
Exists *bool
Locked *bool
Functional *bool
LockedBySteamID string
LockedAt time.Time
ObservedAt time.Time
@@ -223,6 +225,7 @@ func CopySCUMUserTrajectories(values []SCUMUserTrajectory) []SCUMUserTrajectory
}
func CopySCUMVehicle(value SCUMVehicle) SCUMVehicle {
value.Functional = copyBoolPtr(value.Functional)
value.X = copyFloatPtr(value.X)
value.Y = copyFloatPtr(value.Y)
value.Z = copyFloatPtr(value.Z)
@@ -293,6 +296,7 @@ func CopySCUMVehicleFacts(values []SCUMVehicleFact) []SCUMVehicleFact {
out[i] = value
out[i].Exists = copyBoolPtr(value.Exists)
out[i].Locked = copyBoolPtr(value.Locked)
out[i].Functional = copyBoolPtr(value.Functional)
if value.Position != nil {
position := *value.Position
out[i].Position = &position
+4 -2
View File
@@ -84,6 +84,7 @@ type SCUMVehicleResponse struct {
Exists bool `json:"exists"`
Status string `json:"status"`
Locked bool `json:"locked"`
Functional *bool `json:"functional,omitempty"`
X *float64 `json:"x,omitempty"`
Y *float64 `json:"y,omitempty"`
Z *float64 `json:"z,omitempty"`
@@ -187,6 +188,7 @@ type SCUMVehicleFactBody struct {
DisplayName string `json:"displayName,omitempty"`
Exists *bool `json:"exists,omitempty"`
Locked *bool `json:"locked,omitempty"`
Functional *bool `json:"functional,omitempty"`
LockedBySteamID string `json:"lockedBySteamId,omitempty"`
LockedAt time.Time `json:"lockedAt,omitempty"`
ObservedAt time.Time `json:"observedAt,omitempty"`
@@ -240,7 +242,7 @@ func SCUMVehicleFromDomain(value domain.SCUMVehicle) SCUMVehicleResponse {
if value.Exists {
status = "exists"
}
return SCUMVehicleResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, VehicleID: value.ID, GameVehicleID: value.GameVehicleID, EntityID: value.GameVehicleID, VehicleClass: value.VehicleClass, ClassName: value.VehicleClass, DisplayName: value.DisplayName, Label: value.DisplayName, Exists: value.Exists, Status: status, Locked: value.Locked, X: value.X, Y: value.Y, Z: value.Z, Position: positionFromPointers(value.X, value.Y, value.Z), LastObservedAt: value.LastObservedAt, VehicleObservedAt: value.LastObservedAt, Source: "platform.scum_vehicle", CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt}
return SCUMVehicleResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, VehicleID: value.ID, GameVehicleID: value.GameVehicleID, EntityID: value.GameVehicleID, VehicleClass: value.VehicleClass, ClassName: value.VehicleClass, DisplayName: value.DisplayName, Label: value.DisplayName, Exists: value.Exists, Status: status, Locked: value.Locked, Functional: value.Functional, X: value.X, Y: value.Y, Z: value.Z, Position: positionFromPointers(value.X, value.Y, value.Z), LastObservedAt: value.LastObservedAt, VehicleObservedAt: value.LastObservedAt, Source: "platform.scum_vehicle", CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt}
}
func SCUMVehicleTrajectoryListFromDomain(values []domain.SCUMVehicleTrajectory) SCUMVehicleTrajectoryListResponse {
@@ -284,7 +286,7 @@ func (request SCUMUserFactBody) ToDomain() domain.SCUMUserFact {
}
func (request SCUMVehicleFactBody) ToDomain() domain.SCUMVehicleFact {
return domain.SCUMVehicleFact{GameVehicleID: request.GameVehicleID, VehicleClass: request.VehicleClass, DisplayName: request.DisplayName, Exists: request.Exists, Locked: request.Locked, LockedBySteamID: request.LockedBySteamID, LockedAt: request.LockedAt, ObservedAt: request.ObservedAt, Position: positionToDomain(request.Position)}
return domain.SCUMVehicleFact{GameVehicleID: request.GameVehicleID, VehicleClass: request.VehicleClass, DisplayName: request.DisplayName, Exists: request.Exists, Locked: request.Locked, Functional: request.Functional, LockedBySteamID: request.LockedBySteamID, LockedAt: request.LockedAt, ObservedAt: request.ObservedAt, Position: positionToDomain(request.Position)}
}
func SCUMFactIngestFromDomain(result domain.SCUMFactIngestResult) SCUMFactIngestResponse {
+2
View File
@@ -95,6 +95,8 @@ type SCUMVehicle struct {
Exists bool `json:"exists" db:"exists"`
// Locked marks the latest observed lock state.
Locked bool `json:"locked" db:"locked"`
// Functional is the latest game-reported vehicle functional state when available.
Functional *bool `json:"functional,omitempty" db:"functional"`
// X stores the latest observed world X coordinate.
X *float64 `json:"x,omitempty" db:"x"`
// Y stores the latest observed world Y coordinate.
+23 -5
View File
@@ -78,6 +78,7 @@ var scumTableDDL = []struct {
display_name VARCHAR(191) NOT NULL DEFAULT '',
exists_in_game BOOLEAN NOT NULL DEFAULT FALSE,
locked BOOLEAN NOT NULL DEFAULT FALSE,
functional BOOLEAN NULL,
x DOUBLE NULL,
y DOUBLE NULL,
z DOUBLE NULL,
@@ -136,6 +137,23 @@ func (store *MySQLStore) initializeSCUMTables(ctx context.Context) error {
return fmt.Errorf("create SCUM table %s: %w", entry.table, err)
}
}
if err := store.ensureSCUMVehicleFunctionalColumn(ctx); err != nil {
return err
}
return nil
}
func (store *MySQLStore) ensureSCUMVehicleFunctionalColumn(ctx context.Context) error {
var found int
if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'scum_vehicle' AND COLUMN_NAME = 'functional'`).Scan(&found); err != nil {
return fmt.Errorf("inspect SCUM vehicle functional column: %w", err)
}
if found > 0 {
return nil
}
if _, err := store.db.ExecContext(ctx, `ALTER TABLE scum_vehicle ADD COLUMN functional BOOLEAN NULL AFTER locked`); err != nil {
return fmt.Errorf("add SCUM vehicle functional column: %w", err)
}
return nil
}
@@ -268,12 +286,12 @@ func (repository *mysqlSCUMUserRepository) Delete(id string) error {
type mysqlSCUMVehicleRepository struct{ db *sql.DB }
const scumVehicleColumns = `id, server_instance_id, game_vehicle_id, vehicle_class, display_name, exists_in_game, locked, x, y, z, last_observed_at, created_at, updated_at`
const scumVehicleColumns = `id, server_instance_id, game_vehicle_id, vehicle_class, display_name, exists_in_game, locked, functional, x, y, z, last_observed_at, created_at, updated_at`
func (repository *mysqlSCUMVehicleRepository) Create(value domain.SCUMVehicle) error {
ctx, cancel := scumQueryContext()
defer cancel()
_, err := repository.db.ExecContext(ctx, `INSERT INTO scum_vehicle (`+scumVehicleColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, scumVehicleArgs(value)...)
_, err := repository.db.ExecContext(ctx, `INSERT INTO scum_vehicle (`+scumVehicleColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, scumVehicleArgs(value)...)
if err != nil {
if isMySQLDuplicateKey(err) {
return ErrDuplicate
@@ -337,7 +355,7 @@ func (repository *mysqlSCUMVehicleRepository) List(filter domain.SCUMVehicleFilt
func (repository *mysqlSCUMVehicleRepository) Update(value domain.SCUMVehicle) error {
ctx, cancel := scumQueryContext()
defer cancel()
result, err := repository.db.ExecContext(ctx, `UPDATE scum_vehicle SET server_instance_id=?, game_vehicle_id=?, vehicle_class=?, display_name=?, exists_in_game=?, locked=?, x=?, y=?, z=?, last_observed_at=?, created_at=?, updated_at=? WHERE id=?`, scumVehicleUpdateArgs(value)...)
result, err := repository.db.ExecContext(ctx, `UPDATE scum_vehicle SET server_instance_id=?, game_vehicle_id=?, vehicle_class=?, display_name=?, exists_in_game=?, locked=?, functional=?, x=?, y=?, z=?, last_observed_at=?, created_at=?, updated_at=? WHERE id=?`, scumVehicleUpdateArgs(value)...)
if err != nil {
return fmt.Errorf("update scum_vehicle: %w", err)
}
@@ -754,7 +772,7 @@ func scumUserUpdateArgs(value domain.SCUMUser) []any {
func scumVehicleArgs(value domain.SCUMVehicle) []any {
return []any{
value.ID, value.ServerInstanceID, value.GameVehicleID, value.VehicleClass, value.DisplayName,
value.Exists, value.Locked, scumFloatValue(value.X), scumFloatValue(value.Y), scumFloatValue(value.Z),
value.Exists, value.Locked, value.Functional, scumFloatValue(value.X), scumFloatValue(value.Y), scumFloatValue(value.Z),
scumTimeValue(value.LastObservedAt), value.CreatedAt.UTC(), value.UpdatedAt.UTC(),
}
}
@@ -807,7 +825,7 @@ func scanSCUMVehicle(scanner scumRowScanner) (domain.SCUMVehicle, error) {
)
err := scanner.Scan(
&value.ID, &value.ServerInstanceID, &value.GameVehicleID, &value.VehicleClass, &value.DisplayName,
&value.Exists, &value.Locked, &x, &y, &z, &lastObservedAt, &value.CreatedAt, &value.UpdatedAt,
&value.Exists, &value.Locked, &value.Functional, &x, &y, &z, &lastObservedAt, &value.CreatedAt, &value.UpdatedAt,
)
if err != nil {
return domain.SCUMVehicle{}, err
+4
View File
@@ -405,6 +405,10 @@ func (svc *CoreService) ingestSCUMVehicleFact(instance domain.ServerInstance, fa
if fact.Locked != nil {
vehicle.Locked = *fact.Locked
}
if fact.Functional != nil {
functional := *fact.Functional
vehicle.Functional = &functional
}
if fact.Position != nil {
vehicle.X, vehicle.Y, vehicle.Z = floatPtr(fact.Position.X), floatPtr(fact.Position.Y), floatPtr(fact.Position.Z)
}
+4
View File
@@ -535,6 +535,10 @@ func scumVehicleFactsFromRows(rows []scumQueryRow, projection domain.GameClientB
DisplayName: scumRowText(row, projection, "displayName"),
LockedBySteamID: scumRowText(row, projection, "lockedBySteamId"),
}
if functional, ok := scumRowBool(row, projection, "functional"); ok {
value := functional
fact.Functional = &value
}
if exists, ok := scumRowBool(row, projection, "exists"); ok {
value := exists
fact.Exists = &value
+3 -3
View File
@@ -46,7 +46,7 @@ func newSCUMQueryIngestFixture(t *testing.T) (*CoreService, string, domain.Serve
PollIntervalSeconds: 120, MaxRows: 500, TimeoutSeconds: 30,
Projections: []domain.GameClientBridgeQueryProjectionDeclaration{{
Collection: scumProjectionVehicles, RowPath: "rows", UpsertKeys: []string{"gameVehicleId"},
FieldMappings: map[string]string{"gameVehicleId": "gameVehicleId", "vehicleClass": "vehicleClass", "displayName": "displayName", "exists": "existsInGame", "x": "x", "y": "y", "z": "z"},
FieldMappings: map[string]string{"gameVehicleId": "gameVehicleId", "vehicleClass": "vehicleClass", "displayName": "displayName", "functional": "functional", "exists": "existsInGame", "x": "x", "y": "y", "z": "z"},
}},
}}},
})
@@ -178,7 +178,7 @@ func TestSCUMQueryProjectionFillsTypedTables(t *testing.T) {
t.Fatalf("project player rows: %v", err)
}
vehicleJob.State = domain.JobStateSucceeded
vehicleJob.ExecutionResult = domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"gameVehicleId":"5923426","vehicleClass":"MountainBike_ES","displayName":"","existsInGame":1,"x":-406400.84375,"y":558707.4375,"z":83294.6171875,"observedAt":"2026-09-16T08:05:00Z"}]}`}
vehicleJob.ExecutionResult = domain.JobExecutionResult{Kind: "sqlite.query", Content: `{"rows":[{"gameVehicleId":"5923426","vehicleClass":"MountainBike_ES","displayName":"","functional":1,"existsInGame":1,"x":-406400.84375,"y":558707.4375,"z":83294.6171875,"observedAt":"2026-09-16T08:05:00Z"}]}`}
if err := svc.projectRemoteAdapterJobResult(vehicleJob, fixedTime); err != nil {
t.Fatalf("project vehicle rows: %v", err)
}
@@ -198,7 +198,7 @@ func TestSCUMQueryProjectionFillsTypedTables(t *testing.T) {
t.Fatalf("expected last login from database evidence, got %s", user.LastLoginAt)
}
vehicles, err := svc.ListSCUMVehiclesForSession(session, domain.SCUMVehicleFilter{ServerInstanceID: instance.ID})
if err != nil || len(vehicles) != 1 || vehicles[0].VehicleClass != "MountainBike_ES" || vehicles[0].X == nil || *vehicles[0].X != -406400.84375 {
if err != nil || len(vehicles) != 1 || vehicles[0].VehicleClass != "MountainBike_ES" || vehicles[0].Functional == nil || !*vehicles[0].Functional || vehicles[0].X == nil || *vehicles[0].X != -406400.84375 {
t.Fatalf("unexpected projected vehicle: vehicles=%+v err=%v", vehicles, err)
}
userTracks, err := svc.ListSCUMUserTrajectoriesForSession(session, domain.SCUMUserTrajectoryFilter{ServerInstanceID: instance.ID})
+3 -2
View File
@@ -15,6 +15,7 @@ func TestSCUMFactIngestMaintainsPlatformTablesAndWelcomeQueue(t *testing.T) {
gold := int64(3)
exists := true
locked := true
functional := true
observedAt := fixedTime.Add(-1 * time.Minute)
result, err := svc.IngestSCUMFacts(domain.SCUMFactIngest{
@@ -26,7 +27,7 @@ func TestSCUMFactIngestMaintainsPlatformTablesAndWelcomeQueue(t *testing.T) {
ObservedAt: observedAt, LoginObservedAt: observedAt, Position: &domain.SCUMPosition{X: 10, Y: 20, Z: 3}, BankBalance: &bank, GoldBars: &gold, RiddenGameVehicleID: "game-veh-1",
}},
Vehicles: []domain.SCUMVehicleFact{{
GameVehicleID: "game-veh-1", VehicleClass: "BPC_Laika_C", DisplayName: "Laika", Exists: &exists, Locked: &locked, LockedBySteamID: "76561198000000001", LockedAt: observedAt, ObservedAt: observedAt, Position: &domain.SCUMPosition{X: 400, Y: 200, Z: 0},
GameVehicleID: "game-veh-1", VehicleClass: "BPC_Laika_C", DisplayName: "Laika", Exists: &exists, Locked: &locked, Functional: &functional, LockedBySteamID: "76561198000000001", LockedAt: observedAt, ObservedAt: observedAt, Position: &domain.SCUMPosition{X: 400, Y: 200, Z: 0},
}},
})
if err != nil {
@@ -61,7 +62,7 @@ func TestSCUMFactIngestMaintainsPlatformTablesAndWelcomeQueue(t *testing.T) {
t.Fatalf("unexpected user trajectory rows: rows=%+v err=%v", userTracks, err)
}
vehicles, err := svc.ListSCUMVehiclesForSession(session, domain.SCUMVehicleFilter{ServerInstanceID: instance.ID})
if err != nil || len(vehicles) != 1 || vehicles[0].GameVehicleID != "game-veh-1" || !vehicles[0].Exists || !vehicles[0].Locked {
if err != nil || len(vehicles) != 1 || vehicles[0].GameVehicleID != "game-veh-1" || !vehicles[0].Exists || !vehicles[0].Locked || vehicles[0].Functional == nil || !*vehicles[0].Functional {
t.Fatalf("unexpected SCUM vehicles: vehicles=%+v err=%v", vehicles, err)
}
vehicleTracks, err := svc.ListSCUMVehicleTrajectoriesForSession(session, domain.SCUMVehicleTrajectoryFilter{ServerInstanceID: instance.ID})