548 lines
22 KiB
Go
548 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.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)
|
|
}
|
|
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
|
|
}
|