Run pushes typed SCUM facts through POST /api/v1/run/scum/facts, but the production router rejected that path before the signature middleware because runServiceRequest listed individual Run channel prefixes, and MySQL kept SCUM rows inside the whole metadata snapshot instead of platform tables. - /api/v1/run/ is now the signed machine channel space while /api/v1/run/endpoints keeps normal bearer/admin authorization. - MySQL gets real scum_user, scum_user_trajectory, scum_vehicle, scum_vehicle_trajectory and scum_vehicle_lock tables with parameterized per-row repositories instead of full snapshot rewrites. Snapshot-shaped tables from the unreleased interim build are replaced, and SCUM rows still inside a metadata snapshot are migrated once. - Facts ingest verifies the target server plugin type and converges stale online users to offline after SCUMUserOfflineAfter. - The plugin page and browser read one bounded /scum/surface response instead of five list calls per refresh. Call-count budget for one server: per 5s facts batch, one SELECT plus one INSERT/UPDATE per reported user and vehicle, one INSERT per moved trajectory sample or new lock row, one bounded stale-user SELECT, and a trajectory retention DELETE at most once per hour. One browser refresh issues one surface request every 15s instead of five list requests.
522 lines
21 KiB
Go
522 lines
21 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"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
|
|
}
|
|
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 len(batch.Users) > 1000 || len(batch.Vehicles) > 1000 {
|
|
return domain.SCUMFactIngestResult{}, validationError("SCUM fact batch is too large")
|
|
}
|
|
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
|
|
}
|
|
stamp := svc.now()
|
|
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)
|
|
if fact.Login || created {
|
|
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
|
|
}
|