Files
browser/platform/service/scum.go
T
npc0-hue 300390dc4d Fill the SCUM user and vehicle tables from the plugin-declared database read path
The SCUM 用户管理 list stayed empty because the typed scum_user and
scum_vehicle tables had no producer: the run ingest endpoint is signed-run
only, and the previously registered plugin templates asked for a Run
database capability the endpoint never advertises.

The plugin now declares bounded, read-only SQLite projections for players
and vehicles (sql/scum-db-v57/*.sql with query schemas), and the platform
dispatches those templates as durable remote.run.db.sqlite.query jobs on
run heartbeat and page open, then projects the returned rows into the typed
SCUM tables through the same shared ingest path used by signed run facts.
2026-09-16 18:01:33 +08:00

544 lines
22 KiB
Go

package service
import (
"errors"
"fmt"
"log"
"sort"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
func (svc *CoreService) ListSCUMUsersForSession(sessionID string, filter domain.SCUMUserFilter) ([]domain.SCUMUser, error) {
if strings.TrimSpace(filter.ServerInstanceID) == "" {
return nil, validationError("serverInstanceId is required")
}
if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
if _, err := svc.reconcileSCUMUserPresence(filter.ServerInstanceID, svc.now()); err != nil {
return nil, err
}
users, err := svc.store.SCUMUsers().List(filter)
if err != nil {
return nil, err
}
sort.SliceStable(users, func(i, j int) bool {
if !users[i].LastActivityAt.Equal(users[j].LastActivityAt) {
return users[i].LastActivityAt.After(users[j].LastActivityAt)
}
return users[i].SteamID < users[j].SteamID
})
limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultListLimit)
if len(users) > limit {
users = users[:limit]
}
stamp := svc.now()
for index := range users {
if !users[index].LastLoginAt.IsZero() {
users[index].LastLoginAge = stamp.Sub(users[index].LastLoginAt)
}
}
return domain.CopySCUMUsers(users), nil
}
func (svc *CoreService) ListSCUMUserTrajectoriesForSession(sessionID string, filter domain.SCUMUserTrajectoryFilter) ([]domain.SCUMUserTrajectory, error) {
if strings.TrimSpace(filter.ServerInstanceID) == "" {
return nil, validationError("serverInstanceId is required")
}
if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
items, err := svc.store.SCUMUserTrajectories().List(filter)
if err != nil {
return nil, err
}
sort.SliceStable(items, func(i, j int) bool {
if !items[i].ObservedAt.Equal(items[j].ObservedAt) {
return items[i].ObservedAt.After(items[j].ObservedAt)
}
return items[i].ID < items[j].ID
})
limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultTrajectoryLimit)
if len(items) > limit {
items = items[:limit]
}
return domain.CopySCUMUserTrajectories(items), nil
}
func (svc *CoreService) ListSCUMVehiclesForSession(sessionID string, filter domain.SCUMVehicleFilter) ([]domain.SCUMVehicle, error) {
if strings.TrimSpace(filter.ServerInstanceID) == "" {
return nil, validationError("serverInstanceId is required")
}
if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
vehicles, err := svc.store.SCUMVehicles().List(filter)
if err != nil {
return nil, err
}
sort.SliceStable(vehicles, func(i, j int) bool {
if !vehicles[i].LastObservedAt.Equal(vehicles[j].LastObservedAt) {
return vehicles[i].LastObservedAt.After(vehicles[j].LastObservedAt)
}
return vehicles[i].GameVehicleID < vehicles[j].GameVehicleID
})
limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultListLimit)
if len(vehicles) > limit {
vehicles = vehicles[:limit]
}
return domain.CopySCUMVehicles(vehicles), nil
}
func (svc *CoreService) ListSCUMVehicleTrajectoriesForSession(sessionID string, filter domain.SCUMVehicleTrajectoryFilter) ([]domain.SCUMVehicleTrajectory, error) {
if strings.TrimSpace(filter.ServerInstanceID) == "" {
return nil, validationError("serverInstanceId is required")
}
if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
items, err := svc.store.SCUMVehicleTrajectories().List(filter)
if err != nil {
return nil, err
}
sort.SliceStable(items, func(i, j int) bool {
if !items[i].ObservedAt.Equal(items[j].ObservedAt) {
return items[i].ObservedAt.After(items[j].ObservedAt)
}
return items[i].ID < items[j].ID
})
limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultTrajectoryLimit)
if len(items) > limit {
items = items[:limit]
}
return domain.CopySCUMVehicleTrajectories(items), nil
}
func (svc *CoreService) ListSCUMVehicleLocksForSession(sessionID string, filter domain.SCUMVehicleLockFilter) ([]domain.SCUMVehicleLock, error) {
if strings.TrimSpace(filter.ServerInstanceID) == "" {
return nil, validationError("serverInstanceId is required")
}
if _, err := svc.GetServerInstanceForSession(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
items, err := svc.store.SCUMVehicleLocks().List(filter)
if err != nil {
return nil, err
}
sort.SliceStable(items, func(i, j int) bool {
if !items[i].LockedAt.Equal(items[j].LockedAt) {
return items[i].LockedAt.After(items[j].LockedAt)
}
return items[i].ID < items[j].ID
})
limit := boundedSCUMLimit(filter.Limit, domain.SCUMDefaultTrajectoryLimit)
if len(items) > limit {
items = items[:limit]
}
return domain.CopySCUMVehicleLocks(items), nil
}
func (svc *CoreService) GetSCUMSurfaceForSession(sessionID, serverInstanceID string) (domain.SCUMSurface, error) {
if strings.TrimSpace(serverInstanceID) == "" {
return domain.SCUMSurface{}, validationError("serverInstanceId is required")
}
if _, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID); err != nil {
return domain.SCUMSurface{}, err
}
if _, err := svc.reconcileSCUMUserPresence(serverInstanceID, svc.now()); err != nil {
return domain.SCUMSurface{}, err
}
if err := svc.ReconcileSCUMQueryTemplates(serverInstanceID); err != nil {
log.Printf("SCUM query template dispatch skipped server=%s error=%s", serverInstanceID, err.Error())
}
users, err := svc.store.SCUMUsers().List(domain.SCUMUserFilter{ServerInstanceID: serverInstanceID, Limit: domain.SCUMDefaultListLimit})
if err != nil {
return domain.SCUMSurface{}, err
}
vehicles, err := svc.store.SCUMVehicles().List(domain.SCUMVehicleFilter{ServerInstanceID: serverInstanceID, Limit: domain.SCUMDefaultListLimit})
if err != nil {
return domain.SCUMSurface{}, err
}
userTracks, err := svc.store.SCUMUserTrajectories().List(domain.SCUMUserTrajectoryFilter{ServerInstanceID: serverInstanceID, Limit: domain.SCUMDefaultTrajectoryLimit})
if err != nil {
return domain.SCUMSurface{}, err
}
vehicleTracks, err := svc.store.SCUMVehicleTrajectories().List(domain.SCUMVehicleTrajectoryFilter{ServerInstanceID: serverInstanceID, Limit: domain.SCUMDefaultTrajectoryLimit})
if err != nil {
return domain.SCUMSurface{}, err
}
locks, err := svc.store.SCUMVehicleLocks().List(domain.SCUMVehicleLockFilter{ServerInstanceID: serverInstanceID, Limit: domain.SCUMDefaultTrajectoryLimit})
if err != nil {
return domain.SCUMSurface{}, err
}
return domain.SCUMSurface{Users: limitSCUMUsers(users), Vehicles: limitSCUMVehicles(vehicles), UserTrajectories: limitSCUMUserTrajectories(userTracks), VehicleTrajectories: limitSCUMVehicleTrajectories(vehicleTracks), VehicleLocks: limitSCUMVehicleLocks(locks)}, nil
}
func limitSCUMUsers(values []domain.SCUMUser) []domain.SCUMUser {
sort.SliceStable(values, func(i, j int) bool { return values[i].LastActivityAt.After(values[j].LastActivityAt) })
if len(values) > domain.SCUMDefaultListLimit {
values = values[:domain.SCUMDefaultListLimit]
}
return domain.CopySCUMUsers(values)
}
func limitSCUMVehicles(values []domain.SCUMVehicle) []domain.SCUMVehicle {
sort.SliceStable(values, func(i, j int) bool { return values[i].LastObservedAt.After(values[j].LastObservedAt) })
if len(values) > domain.SCUMDefaultListLimit {
values = values[:domain.SCUMDefaultListLimit]
}
return domain.CopySCUMVehicles(values)
}
func limitSCUMUserTrajectories(values []domain.SCUMUserTrajectory) []domain.SCUMUserTrajectory {
sort.SliceStable(values, func(i, j int) bool { return values[i].ObservedAt.After(values[j].ObservedAt) })
if len(values) > domain.SCUMDefaultTrajectoryLimit {
values = values[:domain.SCUMDefaultTrajectoryLimit]
}
return domain.CopySCUMUserTrajectories(values)
}
func limitSCUMVehicleTrajectories(values []domain.SCUMVehicleTrajectory) []domain.SCUMVehicleTrajectory {
sort.SliceStable(values, func(i, j int) bool { return values[i].ObservedAt.After(values[j].ObservedAt) })
if len(values) > domain.SCUMDefaultTrajectoryLimit {
values = values[:domain.SCUMDefaultTrajectoryLimit]
}
return domain.CopySCUMVehicleTrajectories(values)
}
func limitSCUMVehicleLocks(values []domain.SCUMVehicleLock) []domain.SCUMVehicleLock {
sort.SliceStable(values, func(i, j int) bool { return values[i].LockedAt.After(values[j].LockedAt) })
if len(values) > domain.SCUMDefaultTrajectoryLimit {
values = values[:domain.SCUMDefaultTrajectoryLimit]
}
return domain.CopySCUMVehicleLocks(values)
}
func (svc *CoreService) IngestSCUMFacts(batch domain.SCUMFactIngest) (domain.SCUMFactIngestResult, error) {
batch = domain.CopySCUMFactIngest(batch)
if strings.TrimSpace(batch.ServerInstanceID) == "" {
return domain.SCUMFactIngestResult{}, validationError("serverInstanceId is required")
}
if len(batch.Users) == 0 && len(batch.Vehicles) == 0 {
return domain.SCUMFactIngestResult{}, validationError("SCUM facts are required")
}
if err := svc.validateRunSession(batch.RunEndpointID, batch.SessionToken); err != nil {
return domain.SCUMFactIngestResult{}, err
}
instance, err := svc.store.ServerInstances().Get(batch.ServerInstanceID)
if err != nil {
return domain.SCUMFactIngestResult{}, err
}
if instance.RunEndpointID != batch.RunEndpointID {
return domain.SCUMFactIngestResult{}, ErrForbidden
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMFactIngestResult{}, err
}
if !strings.EqualFold(strings.TrimSpace(plugin.ServerType), "scum") {
return domain.SCUMFactIngestResult{}, ErrForbidden
}
return svc.ingestSCUMFactsForInstance(instance, batch, svc.now())
}
// ingestSCUMFactsForInstance applies one already-authorized fact batch to the
// typed SCUM tables. Both the signed run ingest channel and the plugin-declared
// database projection path converge here.
//
// Call-count budget for one batch: one bounded stale-user convergence read per
// instance, then one read plus one insert/update per reported user and vehicle,
// and at most one trajectory sample per moved subject.
func (svc *CoreService) ingestSCUMFactsForInstance(instance domain.ServerInstance, batch domain.SCUMFactIngest, stamp time.Time) (domain.SCUMFactIngestResult, error) {
batch = domain.CopySCUMFactIngest(batch)
if len(batch.Users) == 0 && len(batch.Vehicles) == 0 {
return domain.SCUMFactIngestResult{}, validationError("SCUM facts are required")
}
if len(batch.Users) > domain.SCUMFactBatchLimit || len(batch.Vehicles) > domain.SCUMFactBatchLimit {
return domain.SCUMFactIngestResult{}, validationError("SCUM fact batch is too large")
}
if _, err := svc.reconcileSCUMUserPresence(instance.ID, stamp); err != nil {
return domain.SCUMFactIngestResult{}, err
}
welcomeCount := 0
for _, fact := range batch.Users {
if err := svc.ingestSCUMUserFact(instance, fact, stamp, &welcomeCount); err != nil {
return domain.SCUMFactIngestResult{}, err
}
}
for _, fact := range batch.Vehicles {
if err := svc.ingestSCUMVehicleFact(instance, fact, stamp); err != nil {
return domain.SCUMFactIngestResult{}, err
}
}
return domain.SCUMFactIngestResult{Accepted: true, AcceptedUserCount: len(batch.Users), AcceptedVehicleCount: len(batch.Vehicles), WelcomeQueuedCount: welcomeCount, ServerTime: stamp}, nil
}
// reconcileSCUMUserPresence converges stale online flags from the last typed
// Run fact. A missing offline fact is treated as a presence timeout only; no
// trajectory row is written and the last observed coordinates stay untouched.
func (svc *CoreService) reconcileSCUMUserPresence(serverInstanceID string, stamp time.Time) (int, error) {
online := true
stale, err := svc.store.SCUMUsers().List(domain.SCUMUserFilter{
ServerInstanceID: serverInstanceID,
Online: &online,
StaleBefore: stamp.Add(-domain.SCUMUserOfflineAfter),
})
if err != nil {
return 0, err
}
updated := 0
for _, user := range stale {
user.Online = false
user.UpdatedAt = stamp
if err := svc.store.SCUMUsers().Update(user); err != nil {
return updated, err
}
updated++
}
return updated, nil
}
func (svc *CoreService) ingestSCUMUserFact(instance domain.ServerInstance, fact domain.SCUMUserFact, stamp time.Time, welcomeCount *int) error {
steamID := strings.TrimSpace(fact.SteamID)
if steamID == "" {
return validationError("SCUM user steamId is required")
}
observedAt := nonZeroTime(fact.ObservedAt, stamp)
loginAt := nonZeroTime(fact.LoginObservedAt, observedAt)
userID := scumUserID(instance.ID, steamID)
user, err := svc.store.SCUMUsers().Get(userID)
created := false
if errors.Is(err, repo.ErrNotFound) {
created = true
user = domain.SCUMUser{ID: userID, ServerInstanceID: instance.ID, SteamID: steamID, RegisteredAt: loginAt, CreatedAt: stamp}
} else if err != nil {
return err
}
previousActivity := user.LastActivityAt
freshLogin := created || (fact.Login && (previousActivity.IsZero() || loginAt.Sub(previousActivity) > domain.SCUMUserOfflineAfter))
moved := fact.Online && fact.Position != nil && userPositionMoved(user, fact.Position)
loginEvidence := fact.Login || created
if !loginEvidence && !fact.LoginObservedAt.IsZero() && loginAt.After(user.LastLoginAt) {
loginEvidence = true
}
if loginEvidence {
user.LastLoginAt = loginAt
}
user.DisplayName = firstNonBlank(fact.DisplayName, user.DisplayName)
if fact.SquadID != nil {
user.SquadID = *fact.SquadID
}
user.LastLoginIP = firstNonBlank(fact.LoginIP, user.LastLoginIP)
user.Online = fact.Online
user.LastActivityAt = latestTime(user.LastActivityAt, observedAt)
if fact.Position != nil {
user.X, user.Y, user.Z = floatPtr(fact.Position.X), floatPtr(fact.Position.Y), floatPtr(fact.Position.Z)
}
if fact.BankBalance != nil {
user.BankBalance = int64Ptr(*fact.BankBalance)
}
if fact.GoldBars != nil {
user.GoldBars = int64Ptr(*fact.GoldBars)
}
user.GameVehicleID = strings.TrimSpace(fact.RiddenGameVehicleID)
if user.GameVehicleID != "" {
user.RiddenVehicleID = scumVehicleID(instance.ID, user.GameVehicleID)
} else {
user.RiddenVehicleID = ""
}
user.UpdatedAt = stamp
if created {
if err := svc.store.SCUMUsers().Create(user); err != nil {
return err
}
} else if err := svc.store.SCUMUsers().Update(user); err != nil {
return err
}
if moved {
track := domain.SCUMUserTrajectory{ID: scumTrajectoryID("scum-user-track", instance.ID, steamID, observedAt), ServerInstanceID: instance.ID, SCUMUserID: user.ID, SteamID: steamID, DisplayName: user.DisplayName, X: fact.Position.X, Y: fact.Position.Y, Z: fact.Position.Z, RiddenVehicleID: user.RiddenVehicleID, GameVehicleID: user.GameVehicleID, ObservedAt: observedAt, CreatedAt: stamp}
if err := createUnlessDuplicate(svc.store.SCUMUserTrajectories(), track); err != nil {
return err
}
}
if freshLogin {
queued, queueErr := svc.queueSCUMWelcomeMessage(instance, user, stamp)
if queueErr != nil {
user.WelcomeLastError = queueErr.Error()
} else if queued {
user.WelcomeQueuedAt = stamp.Add(domain.SCUMWelcomeMessageDelay)
*welcomeCount = *welcomeCount + 1
}
user.UpdatedAt = stamp
if err := svc.store.SCUMUsers().Update(user); err != nil {
return err
}
}
return nil
}
func (svc *CoreService) ingestSCUMVehicleFact(instance domain.ServerInstance, fact domain.SCUMVehicleFact, stamp time.Time) error {
gameVehicleID := strings.TrimSpace(fact.GameVehicleID)
if gameVehicleID == "" {
return validationError("SCUM vehicle gameVehicleId is required")
}
observedAt := nonZeroTime(fact.ObservedAt, stamp)
vehicleID := scumVehicleID(instance.ID, gameVehicleID)
vehicle, err := svc.store.SCUMVehicles().Get(vehicleID)
created := false
if errors.Is(err, repo.ErrNotFound) {
created = true
vehicle = domain.SCUMVehicle{ID: vehicleID, ServerInstanceID: instance.ID, GameVehicleID: gameVehicleID, Exists: true, CreatedAt: stamp}
} else if err != nil {
return err
}
moved := fact.Position != nil && vehiclePositionMoved(vehicle, fact.Position)
vehicle.VehicleClass = firstNonBlank(fact.VehicleClass, vehicle.VehicleClass)
vehicle.DisplayName = firstNonBlank(fact.DisplayName, vehicle.DisplayName)
if fact.Exists != nil {
vehicle.Exists = *fact.Exists
}
if fact.Locked != nil {
vehicle.Locked = *fact.Locked
}
if fact.Position != nil {
vehicle.X, vehicle.Y, vehicle.Z = floatPtr(fact.Position.X), floatPtr(fact.Position.Y), floatPtr(fact.Position.Z)
}
vehicle.LastObservedAt = latestTime(vehicle.LastObservedAt, observedAt)
vehicle.UpdatedAt = stamp
if created {
if err := svc.store.SCUMVehicles().Create(vehicle); err != nil {
return err
}
} else if err := svc.store.SCUMVehicles().Update(vehicle); err != nil {
return err
}
if moved {
track := domain.SCUMVehicleTrajectory{ID: scumTrajectoryID("scum-vehicle-track", instance.ID, gameVehicleID, observedAt), ServerInstanceID: instance.ID, SCUMVehicleID: vehicle.ID, GameVehicleID: gameVehicleID, VehicleClass: vehicle.VehicleClass, X: fact.Position.X, Y: fact.Position.Y, Z: fact.Position.Z, ObservedAt: observedAt, CreatedAt: stamp}
if err := createUnlessDuplicate(svc.store.SCUMVehicleTrajectories(), track); err != nil {
return err
}
}
if strings.TrimSpace(fact.LockedBySteamID) != "" && !fact.LockedAt.IsZero() {
lockUserID := scumUserID(instance.ID, strings.TrimSpace(fact.LockedBySteamID))
lock := domain.SCUMVehicleLock{ID: scumTrajectoryID("scum-vehicle-lock", instance.ID, gameVehicleID+":"+fact.LockedBySteamID, fact.LockedAt), ServerInstanceID: instance.ID, SCUMVehicleID: vehicle.ID, GameVehicleID: gameVehicleID, SCUMUserID: lockUserID, SteamID: strings.TrimSpace(fact.LockedBySteamID), LockedAt: fact.LockedAt, CreatedAt: stamp}
if err := createUnlessDuplicate(svc.store.SCUMVehicleLocks(), lock); err != nil {
return err
}
}
return nil
}
func (svc *CoreService) queueSCUMWelcomeMessage(instance domain.ServerInstance, user domain.SCUMUser, stamp time.Time) (bool, error) {
if instance.State != domain.ServerInstanceStateRunning {
return false, nil
}
resolution, err := svc.resolveSourceRCONDispatch(instance)
if err != nil {
return false, err
}
message := fmt.Sprintf("欢迎 %s 回到服务器", firstNonBlank(user.DisplayName, user.SteamID))
request := domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindChat, ChatType: 4, Message: message, IdempotencyKey: scumWelcomeIdempotencyKey(instance.ID, user.SteamID, user.LastLoginAt)}
if err := validator.ValidateSourceRCONCommandRequest(request); err != nil {
return false, err
}
if existing, err := svc.store.Jobs().GetByIdempotency(instance.RunEndpointID, request.IdempotencyKey); err == nil {
return existing.QueueEligibleAt.Equal(stamp.Add(domain.SCUMWelcomeMessageDelay)) || !existing.QueueEligibleAt.IsZero(), nil
} else if !errors.Is(err, repo.ErrNotFound) {
return false, err
}
command := sourceRCONCommandText(request)
jobID := jobIDFromParts("job-source-rcon", instance.ID, request.IdempotencyKey)
if err := svc.sourceRCONCommands.Put(jobID, command); err != nil {
return false, err
}
job := domain.Job{ID: jobID, ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, Capability: domain.JobCapabilityRemoteRunRCONCommand, TargetKey: resolution.transport.TargetKey, InputRef: "input://source-rcon/" + jobID, IdempotencyKey: request.IdempotencyKey, State: domain.JobStateQueued, Progress: domain.JobProgress{Percent: 0, Message: "SCUM welcome message queued"}, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, QueueEligibleAt: stamp.Add(domain.SCUMWelcomeMessageDelay), ExecutionInput: domain.JobExecutionInput{WorkspaceScope: resolution.binding.ProfileKey, RemoteAdapterKey: resolution.transport.Key, RemoteAdapterKind: string(domain.RemoteAdapterRCON), TimeoutSeconds: sourceRCONTimeoutSeconds, PluginID: resolution.plugin.ID, SourceRCON: resolution.plan}}
created, err := svc.CreateJob(job)
if err != nil {
svc.sourceRCONCommands.Delete(jobID)
return false, err
}
return created.ID == jobID, nil
}
func userPositionMoved(user domain.SCUMUser, position *domain.SCUMPosition) bool {
if position == nil || user.X == nil || user.Y == nil || user.Z == nil {
return true
}
return *user.X != position.X || *user.Y != position.Y || *user.Z != position.Z
}
func vehiclePositionMoved(vehicle domain.SCUMVehicle, position *domain.SCUMPosition) bool {
if position == nil || vehicle.X == nil || vehicle.Y == nil || vehicle.Z == nil {
return true
}
return *vehicle.X != position.X || *vehicle.Y != position.Y || *vehicle.Z != position.Z
}
func boundedSCUMLimit(requested int, fallback int) int {
if requested <= 0 {
return fallback
}
if requested > 1000 {
return 1000
}
return requested
}
func scumUserID(serverID string, steamID string) string {
return "scum-user-" + fingerprintID(serverID, strings.TrimSpace(steamID))
}
func scumVehicleID(serverID string, gameVehicleID string) string {
return "scum-vehicle-" + fingerprintID(serverID, strings.TrimSpace(gameVehicleID))
}
func scumTrajectoryID(prefix string, serverID string, subjectID string, observedAt time.Time) string {
return prefix + "-" + fingerprintID(serverID, strings.TrimSpace(subjectID)+"\x00"+observedAt.UTC().Format(time.RFC3339Nano))
}
func scumWelcomeIdempotencyKey(serverID string, steamID string, loginAt time.Time) string {
return "scum-welcome:" + fingerprintID(serverID, strings.TrimSpace(steamID)+"\x00"+loginAt.UTC().Format(time.RFC3339Nano))
}
func nonZeroTime(value time.Time, fallback time.Time) time.Time {
if value.IsZero() {
return fallback
}
return value.UTC()
}
func latestTime(left time.Time, right time.Time) time.Time {
if left.IsZero() || right.After(left) {
return right
}
return left
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func floatPtr(value float64) *float64 { return &value }
func int64Ptr(value int64) *int64 { return &value }
type scumCreateOnlyRepository[T any] interface {
Create(T) error
}
func createUnlessDuplicate[T any](repository scumCreateOnlyRepository[T], value T) error {
if err := repository.Create(value); err != nil && !errors.Is(err, repo.ErrDuplicate) {
return err
}
return nil
}