feat: surface SCUM vehicle functional state
This commit is contained in:
@@ -83,6 +83,7 @@ type SCUMVehicle struct {
|
|||||||
DisplayName string
|
DisplayName string
|
||||||
Exists bool
|
Exists bool
|
||||||
Locked bool
|
Locked bool
|
||||||
|
Functional *bool
|
||||||
X *float64
|
X *float64
|
||||||
Y *float64
|
Y *float64
|
||||||
Z *float64
|
Z *float64
|
||||||
@@ -178,6 +179,7 @@ type SCUMVehicleFact struct {
|
|||||||
DisplayName string
|
DisplayName string
|
||||||
Exists *bool
|
Exists *bool
|
||||||
Locked *bool
|
Locked *bool
|
||||||
|
Functional *bool
|
||||||
LockedBySteamID string
|
LockedBySteamID string
|
||||||
LockedAt time.Time
|
LockedAt time.Time
|
||||||
ObservedAt time.Time
|
ObservedAt time.Time
|
||||||
@@ -223,6 +225,7 @@ func CopySCUMUserTrajectories(values []SCUMUserTrajectory) []SCUMUserTrajectory
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CopySCUMVehicle(value SCUMVehicle) SCUMVehicle {
|
func CopySCUMVehicle(value SCUMVehicle) SCUMVehicle {
|
||||||
|
value.Functional = copyBoolPtr(value.Functional)
|
||||||
value.X = copyFloatPtr(value.X)
|
value.X = copyFloatPtr(value.X)
|
||||||
value.Y = copyFloatPtr(value.Y)
|
value.Y = copyFloatPtr(value.Y)
|
||||||
value.Z = copyFloatPtr(value.Z)
|
value.Z = copyFloatPtr(value.Z)
|
||||||
@@ -293,6 +296,7 @@ func CopySCUMVehicleFacts(values []SCUMVehicleFact) []SCUMVehicleFact {
|
|||||||
out[i] = value
|
out[i] = value
|
||||||
out[i].Exists = copyBoolPtr(value.Exists)
|
out[i].Exists = copyBoolPtr(value.Exists)
|
||||||
out[i].Locked = copyBoolPtr(value.Locked)
|
out[i].Locked = copyBoolPtr(value.Locked)
|
||||||
|
out[i].Functional = copyBoolPtr(value.Functional)
|
||||||
if value.Position != nil {
|
if value.Position != nil {
|
||||||
position := *value.Position
|
position := *value.Position
|
||||||
out[i].Position = &position
|
out[i].Position = &position
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ type SCUMVehicleResponse struct {
|
|||||||
Exists bool `json:"exists"`
|
Exists bool `json:"exists"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Locked bool `json:"locked"`
|
Locked bool `json:"locked"`
|
||||||
|
Functional *bool `json:"functional,omitempty"`
|
||||||
X *float64 `json:"x,omitempty"`
|
X *float64 `json:"x,omitempty"`
|
||||||
Y *float64 `json:"y,omitempty"`
|
Y *float64 `json:"y,omitempty"`
|
||||||
Z *float64 `json:"z,omitempty"`
|
Z *float64 `json:"z,omitempty"`
|
||||||
@@ -187,6 +188,7 @@ type SCUMVehicleFactBody struct {
|
|||||||
DisplayName string `json:"displayName,omitempty"`
|
DisplayName string `json:"displayName,omitempty"`
|
||||||
Exists *bool `json:"exists,omitempty"`
|
Exists *bool `json:"exists,omitempty"`
|
||||||
Locked *bool `json:"locked,omitempty"`
|
Locked *bool `json:"locked,omitempty"`
|
||||||
|
Functional *bool `json:"functional,omitempty"`
|
||||||
LockedBySteamID string `json:"lockedBySteamId,omitempty"`
|
LockedBySteamID string `json:"lockedBySteamId,omitempty"`
|
||||||
LockedAt time.Time `json:"lockedAt,omitempty"`
|
LockedAt time.Time `json:"lockedAt,omitempty"`
|
||||||
ObservedAt time.Time `json:"observedAt,omitempty"`
|
ObservedAt time.Time `json:"observedAt,omitempty"`
|
||||||
@@ -240,7 +242,7 @@ func SCUMVehicleFromDomain(value domain.SCUMVehicle) SCUMVehicleResponse {
|
|||||||
if value.Exists {
|
if value.Exists {
|
||||||
status = "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 {
|
func SCUMVehicleTrajectoryListFromDomain(values []domain.SCUMVehicleTrajectory) SCUMVehicleTrajectoryListResponse {
|
||||||
@@ -284,7 +286,7 @@ func (request SCUMUserFactBody) ToDomain() domain.SCUMUserFact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (request SCUMVehicleFactBody) ToDomain() domain.SCUMVehicleFact {
|
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 {
|
func SCUMFactIngestFromDomain(result domain.SCUMFactIngestResult) SCUMFactIngestResponse {
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ type SCUMVehicle struct {
|
|||||||
Exists bool `json:"exists" db:"exists"`
|
Exists bool `json:"exists" db:"exists"`
|
||||||
// Locked marks the latest observed lock state.
|
// Locked marks the latest observed lock state.
|
||||||
Locked bool `json:"locked" db:"locked"`
|
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 stores the latest observed world X coordinate.
|
||||||
X *float64 `json:"x,omitempty" db:"x"`
|
X *float64 `json:"x,omitempty" db:"x"`
|
||||||
// Y stores the latest observed world Y coordinate.
|
// Y stores the latest observed world Y coordinate.
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ var scumTableDDL = []struct {
|
|||||||
display_name VARCHAR(191) NOT NULL DEFAULT '',
|
display_name VARCHAR(191) NOT NULL DEFAULT '',
|
||||||
exists_in_game BOOLEAN NOT NULL DEFAULT FALSE,
|
exists_in_game BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
locked BOOLEAN NOT NULL DEFAULT FALSE,
|
locked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
functional BOOLEAN NULL,
|
||||||
x DOUBLE NULL,
|
x DOUBLE NULL,
|
||||||
y DOUBLE NULL,
|
y DOUBLE NULL,
|
||||||
z 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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,12 +286,12 @@ func (repository *mysqlSCUMUserRepository) Delete(id string) error {
|
|||||||
|
|
||||||
type mysqlSCUMVehicleRepository struct{ db *sql.DB }
|
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 {
|
func (repository *mysqlSCUMVehicleRepository) Create(value domain.SCUMVehicle) error {
|
||||||
ctx, cancel := scumQueryContext()
|
ctx, cancel := scumQueryContext()
|
||||||
defer cancel()
|
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 err != nil {
|
||||||
if isMySQLDuplicateKey(err) {
|
if isMySQLDuplicateKey(err) {
|
||||||
return ErrDuplicate
|
return ErrDuplicate
|
||||||
@@ -337,7 +355,7 @@ func (repository *mysqlSCUMVehicleRepository) List(filter domain.SCUMVehicleFilt
|
|||||||
func (repository *mysqlSCUMVehicleRepository) Update(value domain.SCUMVehicle) error {
|
func (repository *mysqlSCUMVehicleRepository) Update(value domain.SCUMVehicle) error {
|
||||||
ctx, cancel := scumQueryContext()
|
ctx, cancel := scumQueryContext()
|
||||||
defer cancel()
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("update scum_vehicle: %w", err)
|
return fmt.Errorf("update scum_vehicle: %w", err)
|
||||||
}
|
}
|
||||||
@@ -754,7 +772,7 @@ func scumUserUpdateArgs(value domain.SCUMUser) []any {
|
|||||||
func scumVehicleArgs(value domain.SCUMVehicle) []any {
|
func scumVehicleArgs(value domain.SCUMVehicle) []any {
|
||||||
return []any{
|
return []any{
|
||||||
value.ID, value.ServerInstanceID, value.GameVehicleID, value.VehicleClass, value.DisplayName,
|
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(),
|
scumTimeValue(value.LastObservedAt), value.CreatedAt.UTC(), value.UpdatedAt.UTC(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -807,7 +825,7 @@ func scanSCUMVehicle(scanner scumRowScanner) (domain.SCUMVehicle, error) {
|
|||||||
)
|
)
|
||||||
err := scanner.Scan(
|
err := scanner.Scan(
|
||||||
&value.ID, &value.ServerInstanceID, &value.GameVehicleID, &value.VehicleClass, &value.DisplayName,
|
&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 {
|
if err != nil {
|
||||||
return domain.SCUMVehicle{}, err
|
return domain.SCUMVehicle{}, err
|
||||||
|
|||||||
@@ -405,6 +405,10 @@ func (svc *CoreService) ingestSCUMVehicleFact(instance domain.ServerInstance, fa
|
|||||||
if fact.Locked != nil {
|
if fact.Locked != nil {
|
||||||
vehicle.Locked = *fact.Locked
|
vehicle.Locked = *fact.Locked
|
||||||
}
|
}
|
||||||
|
if fact.Functional != nil {
|
||||||
|
functional := *fact.Functional
|
||||||
|
vehicle.Functional = &functional
|
||||||
|
}
|
||||||
if fact.Position != nil {
|
if fact.Position != nil {
|
||||||
vehicle.X, vehicle.Y, vehicle.Z = floatPtr(fact.Position.X), floatPtr(fact.Position.Y), floatPtr(fact.Position.Z)
|
vehicle.X, vehicle.Y, vehicle.Z = floatPtr(fact.Position.X), floatPtr(fact.Position.Y), floatPtr(fact.Position.Z)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -535,6 +535,10 @@ func scumVehicleFactsFromRows(rows []scumQueryRow, projection domain.GameClientB
|
|||||||
DisplayName: scumRowText(row, projection, "displayName"),
|
DisplayName: scumRowText(row, projection, "displayName"),
|
||||||
LockedBySteamID: scumRowText(row, projection, "lockedBySteamId"),
|
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 {
|
if exists, ok := scumRowBool(row, projection, "exists"); ok {
|
||||||
value := exists
|
value := exists
|
||||||
fact.Exists = &value
|
fact.Exists = &value
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func newSCUMQueryIngestFixture(t *testing.T) (*CoreService, string, domain.Serve
|
|||||||
PollIntervalSeconds: 120, MaxRows: 500, TimeoutSeconds: 30,
|
PollIntervalSeconds: 120, MaxRows: 500, TimeoutSeconds: 30,
|
||||||
Projections: []domain.GameClientBridgeQueryProjectionDeclaration{{
|
Projections: []domain.GameClientBridgeQueryProjectionDeclaration{{
|
||||||
Collection: scumProjectionVehicles, RowPath: "rows", UpsertKeys: []string{"gameVehicleId"},
|
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)
|
t.Fatalf("project player rows: %v", err)
|
||||||
}
|
}
|
||||||
vehicleJob.State = domain.JobStateSucceeded
|
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 {
|
if err := svc.projectRemoteAdapterJobResult(vehicleJob, fixedTime); err != nil {
|
||||||
t.Fatalf("project vehicle rows: %v", err)
|
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)
|
t.Fatalf("expected last login from database evidence, got %s", user.LastLoginAt)
|
||||||
}
|
}
|
||||||
vehicles, err := svc.ListSCUMVehiclesForSession(session, domain.SCUMVehicleFilter{ServerInstanceID: instance.ID})
|
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)
|
t.Fatalf("unexpected projected vehicle: vehicles=%+v err=%v", vehicles, err)
|
||||||
}
|
}
|
||||||
userTracks, err := svc.ListSCUMUserTrajectoriesForSession(session, domain.SCUMUserTrajectoryFilter{ServerInstanceID: instance.ID})
|
userTracks, err := svc.ListSCUMUserTrajectoriesForSession(session, domain.SCUMUserTrajectoryFilter{ServerInstanceID: instance.ID})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ func TestSCUMFactIngestMaintainsPlatformTablesAndWelcomeQueue(t *testing.T) {
|
|||||||
gold := int64(3)
|
gold := int64(3)
|
||||||
exists := true
|
exists := true
|
||||||
locked := true
|
locked := true
|
||||||
|
functional := true
|
||||||
observedAt := fixedTime.Add(-1 * time.Minute)
|
observedAt := fixedTime.Add(-1 * time.Minute)
|
||||||
|
|
||||||
result, err := svc.IngestSCUMFacts(domain.SCUMFactIngest{
|
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",
|
ObservedAt: observedAt, LoginObservedAt: observedAt, Position: &domain.SCUMPosition{X: 10, Y: 20, Z: 3}, BankBalance: &bank, GoldBars: &gold, RiddenGameVehicleID: "game-veh-1",
|
||||||
}},
|
}},
|
||||||
Vehicles: []domain.SCUMVehicleFact{{
|
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 {
|
if err != nil {
|
||||||
@@ -61,7 +62,7 @@ func TestSCUMFactIngestMaintainsPlatformTablesAndWelcomeQueue(t *testing.T) {
|
|||||||
t.Fatalf("unexpected user trajectory rows: rows=%+v err=%v", userTracks, err)
|
t.Fatalf("unexpected user trajectory rows: rows=%+v err=%v", userTracks, err)
|
||||||
}
|
}
|
||||||
vehicles, err := svc.ListSCUMVehiclesForSession(session, domain.SCUMVehicleFilter{ServerInstanceID: instance.ID})
|
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)
|
t.Fatalf("unexpected SCUM vehicles: vehicles=%+v err=%v", vehicles, err)
|
||||||
}
|
}
|
||||||
vehicleTracks, err := svc.ListSCUMVehicleTrajectoriesForSession(session, domain.SCUMVehicleTrajectoryFilter{ServerInstanceID: instance.ID})
|
vehicleTracks, err := svc.ListSCUMVehicleTrajectoriesForSession(session, domain.SCUMVehicleTrajectoryFilter{ServerInstanceID: instance.ID})
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ export interface ScumVehicleResponse {
|
|||||||
exists: boolean;
|
exists: boolean;
|
||||||
status?: string;
|
status?: string;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
|
functional?: boolean;
|
||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
z?: number;
|
z?: number;
|
||||||
|
|||||||
@@ -708,7 +708,7 @@ function mapHoverCard(e: ReactLike["createElement"], point: RecordMap, data: SCU
|
|||||||
} else if (layer === "vehicles") {
|
} else if (layer === "vehicles") {
|
||||||
push("载具", vehicleLabelFor(point));
|
push("载具", vehicleLabelFor(point));
|
||||||
push("类名", textField(point, "className", "vehicleClass", "entityClass", "vehicleType"));
|
push("类名", textField(point, "className", "vehicleClass", "entityClass", "vehicleType"));
|
||||||
push("状态", textField(point, "status", "state") || (boolField(point, "exists", "existsInGame") ? "存在" : ""));
|
push("状态", textField(point, "status", "state") || vehicleFunctionalLabel(point) || (boolField(point, "exists", "existsInGame") ? "存在" : ""));
|
||||||
push("耐久", vehicleDurabilityLabel(point));
|
push("耐久", vehicleDurabilityLabel(point));
|
||||||
push("车锁", vehicleLockLabel(point, data));
|
push("车锁", vehicleLockLabel(point, data));
|
||||||
pushDate("最后观测", "vehicleObservedAt", "lastObservedAt", "observedAt", "updatedAt");
|
pushDate("最后观测", "vehicleObservedAt", "lastObservedAt", "observedAt", "updatedAt");
|
||||||
@@ -760,6 +760,11 @@ function vehicleDurabilityLabel(point: RecordMap): string {
|
|||||||
return value ? `${value}${textField(point, "durabilityMax", "maxHealth") ? ` / ${textField(point, "durabilityMax", "maxHealth")}` : ""}` : "";
|
return value ? `${value}${textField(point, "durabilityMax", "maxHealth") ? ` / ${textField(point, "durabilityMax", "maxHealth")}` : ""}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function vehicleFunctionalLabel(point: RecordMap): string {
|
||||||
|
if (field(point, "functional", "isFunctional") === undefined) return "";
|
||||||
|
return boolField(point, "functional", "isFunctional") ? "可用" : "不可用";
|
||||||
|
}
|
||||||
|
|
||||||
function mapUserMenu(e: ReactLike["createElement"], view: ViewState, users: Array<{ key: string; label: string; count: number }>, labels: Map<string, string>) {
|
function mapUserMenu(e: ReactLike["createElement"], view: ViewState, users: Array<{ key: string; label: string; count: number }>, labels: Map<string, string>) {
|
||||||
const selected = view.mapUsers;
|
const selected = view.mapUsers;
|
||||||
const rows = [...users, ...(selected ?? []).filter((key) => !users.some((user) => user.key === key)).map((key) => ({ key, label: labels.get(key) || key, count: 0 }))];
|
const rows = [...users, ...(selected ?? []).filter((key) => !users.some((user) => user.key === key)).map((key) => ({ key, label: labels.get(key) || key, count: 0 }))];
|
||||||
@@ -1179,7 +1184,7 @@ function mapPointStrip(e: ReactLike["createElement"], point: RecordMap, trails:
|
|||||||
e("span", { className: "provider-id" }, `ID ${textField(point, "subjectId", "id", "_recordKey") || "unknown"}`),
|
e("span", { className: "provider-id" }, `ID ${textField(point, "subjectId", "id", "_recordKey") || "unknown"}`),
|
||||||
e("span", { className: "provider-id" }, `来源 ${textField(point, "source") || "平台表"}`),
|
e("span", { className: "provider-id" }, `来源 ${textField(point, "source") || "平台表"}`),
|
||||||
e("span", { className: "provider-id" }, freshness(point)),
|
e("span", { className: "provider-id" }, freshness(point)),
|
||||||
layerOf(point) === "vehicles" ? e("span", { className: "provider-id" }, `${textField(point, "className", "vehicleClass", "vehicleType") || "unknown"} · ${textField(point, "status", "state", "isFunctional") || "unknown"} · ${boolField(point, "locked") ? "已上锁" : "未上锁/未知"} · 访问 ${dateField(point, "lastAccessTime", "vehicleObservedAt", "sampledAt")}`) : null),
|
layerOf(point) === "vehicles" ? e("span", { className: "provider-id" }, `${textField(point, "className", "vehicleClass", "vehicleType") || "unknown"} · ${textField(point, "status", "state") || vehicleFunctionalLabel(point) || "unknown"} · ${boolField(point, "locked") ? "已上锁" : "未上锁/未知"} · 访问 ${dateField(point, "lastAccessTime", "vehicleObservedAt", "sampledAt")}`) : null),
|
||||||
locks.length ? e("div", { className: "console-row-list" }, locks.map((row, index) => e("div", { key: `selected-lock-${index}`, className: "console-row" }, e("span", null, dateField(row, "lockedAt", "createdAt")), e("strong", null, textField(row, "scumUserId") || "unknown"), e("strong", null, textField(row, "steamId") || "unknown")))) : null,
|
locks.length ? e("div", { className: "console-row-list" }, locks.map((row, index) => e("div", { key: `selected-lock-${index}`, className: "console-row" }, e("span", null, dateField(row, "lockedAt", "createdAt")), e("strong", null, textField(row, "scumUserId") || "unknown"), e("strong", null, textField(row, "steamId") || "unknown")))) : null,
|
||||||
trails.length ? e("div", { className: "console-row-list" }, trails.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") || "平台轨迹表")))) : null
|
trails.length ? e("div", { className: "console-row-list" }, trails.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") || "平台轨迹表")))) : null
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -370,7 +370,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "scum.database.vehicles",
|
"key": "scum.database.vehicles",
|
||||||
"title": "Read SCUM vehicle positions and classes",
|
"title": "Read SCUM vehicle positions, classes, and functional state",
|
||||||
"permission": "server.game-client.read",
|
"permission": "server.game-client.read",
|
||||||
"engine": "sqlite",
|
"engine": "sqlite",
|
||||||
"transportKey": "scum-database",
|
"transportKey": "scum-database",
|
||||||
@@ -392,6 +392,7 @@
|
|||||||
"gameVehicleId": "gameVehicleId",
|
"gameVehicleId": "gameVehicleId",
|
||||||
"vehicleClass": "vehicleClass",
|
"vehicleClass": "vehicleClass",
|
||||||
"displayName": "displayName",
|
"displayName": "displayName",
|
||||||
|
"functional": "functional",
|
||||||
"exists": "existsInGame",
|
"exists": "existsInGame",
|
||||||
"x": "x",
|
"x": "x",
|
||||||
"y": "y",
|
"y": "y",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ SELECT
|
|||||||
CAST(spawner.vehicle_entity_id AS TEXT) AS gameVehicleId,
|
CAST(spawner.vehicle_entity_id AS TEXT) AS gameVehicleId,
|
||||||
entity.class AS vehicleClass,
|
entity.class AS vehicleClass,
|
||||||
spawner.vehicle_alias AS displayName,
|
spawner.vehicle_alias AS displayName,
|
||||||
|
spawner.is_vehicle_functional AS functional,
|
||||||
1 AS existsInGame,
|
1 AS existsInGame,
|
||||||
entity.location_x AS x,
|
entity.location_x AS x,
|
||||||
entity.location_y AS y,
|
entity.location_y AS y,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const surfaceData: SCUMSurfaceData = {
|
|||||||
mapPoints: [{ id: "poi-1", name: "Airfield", layer: "other", x: 800, y: 900, z: 10, source: "plugin-map" }],
|
mapPoints: [{ id: "poi-1", name: "Airfield", layer: "other", x: 800, y: 900, z: 10, source: "plugin-map" }],
|
||||||
mapRegions: [{ id: "region-1", name: "Safe Zone", x: 500, y: 600, z: 0, source: "server-config" }],
|
mapRegions: [{ id: "region-1", name: "Safe Zone", x: 500, y: 600, z: 0, source: "server-config" }],
|
||||||
mapSettings: [],
|
mapSettings: [],
|
||||||
vehicles: [{ id: "scum-vehicle-1", vehicleId: "scum-vehicle-1", gameVehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", locked: true, position: { x: 400, y: 200, z: 0 }, freshness: { status: "fresh" } }],
|
vehicles: [{ id: "scum-vehicle-1", vehicleId: "scum-vehicle-1", gameVehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", functional: true, locked: true, position: { x: 400, y: 200, z: 0 }, freshness: { status: "fresh" } }],
|
||||||
vehicleLocks: [{ id: "lock-1", scumVehicleId: "scum-vehicle-1", gameVehicleId: "veh-1", scumUserId: "scum-user-1", steamId: "76561198000000001", lockedAt: "2026-08-10T00:00:04Z", source: "platform.scum_vehicle_lock" }],
|
vehicleLocks: [{ id: "lock-1", scumVehicleId: "scum-vehicle-1", gameVehicleId: "veh-1", scumUserId: "scum-user-1", steamId: "76561198000000001", lockedAt: "2026-08-10T00:00:04Z", source: "platform.scum_vehicle_lock" }],
|
||||||
trajectories: [
|
trajectories: [
|
||||||
{ subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, sampledAt: "2026-08-10T00:00:03Z", source: "platform.scum_user_trajectory" },
|
{ subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, sampledAt: "2026-08-10T00:00:03Z", source: "platform.scum_user_trajectory" },
|
||||||
@@ -297,7 +297,9 @@ describe("SCUM plugin feature module", () => {
|
|||||||
expect(view.elements.find((element) => element.label === "SCUM 地图图层")?.style?.backgroundImage).toContain("scum-map-terrain-4096.webp");
|
expect(view.elements.find((element) => element.label === "SCUM 地图图层")?.style?.backgroundImage).toContain("scum-map-terrain-4096.webp");
|
||||||
expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["+", "-", "复位", "适应轨迹"]));
|
expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["+", "-", "复位", "适应轨迹"]));
|
||||||
const vehicleView = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data: { ...surfaceData, players: [], mapPoints: [], flags: [], mapRegions: [] } });
|
const vehicleView = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data: { ...surfaceData, players: [], mapPoints: [], flags: [], mapRegions: [] } });
|
||||||
expect(vehicleView.texts).toEqual(expect.arrayContaining(["BPC_Laika_C · unknown · 已上锁 · 访问 unknown", "scum-user-1", "76561198000000001"]));
|
expect(vehicleView.texts).toEqual(expect.arrayContaining(["BPC_Laika_C · 可用 · 已上锁 · 访问 unknown", "scum-user-1", "76561198000000001"]));
|
||||||
|
const unavailableVehicleView = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data: { ...surfaceData, vehicles: [{ ...surfaceData.vehicles[0], functional: false }], players: [], mapPoints: [], flags: [], mapRegions: [] } });
|
||||||
|
expect(unavailableVehicleView.texts).toContain("BPC_Laika_C · 不可用 · 已上锁 · 访问 unknown");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the live map full width with one compact filter row and dropdown menus", () => {
|
it("keeps the live map full width with one compact filter row and dropdown menus", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user