package service import ( "fmt" "math" "strconv" "strings" "time" "browser.local/platform/domain" "browser.local/platform/repo" "browser.local/platform/validator" ) func (svc *CoreService) ApplySCUMObservationResult(result domain.SCUMObservationResult) (domain.SCUMDataObservation, error) { result = domain.CopySCUMObservationResult(result) if strings.TrimSpace(result.ServerInstanceID) == "" { return domain.SCUMDataObservation{}, validationError("serverInstanceId is required") } instance, err := svc.store.ServerInstances().Get(result.ServerInstanceID) if err != nil { return domain.SCUMDataObservation{}, err } if strings.TrimSpace(result.PluginID) == "" { result.PluginID = instance.PluginID } if result.PluginID != instance.PluginID { return domain.SCUMDataObservation{}, validationError("pluginId must match server instance") } if strings.TrimSpace(result.QueryKey) == "" { return domain.SCUMDataObservation{}, validationError("queryKey is required") } if result.ReceivedAt.IsZero() { result.ReceivedAt = svc.now() } if result.ObservedAt.IsZero() { result.ObservedAt = result.ReceivedAt } if result.Status == "" { result.Status = domain.SCUMObservationAccepted } latest, err := svc.latestSCUMObservation(result.ServerInstanceID, result.PluginID, result.QueryKey) if err != nil { return domain.SCUMDataObservation{}, err } if result.Status == domain.SCUMObservationAccepted && !latest.ObservedAt.IsZero() && scumObservationOlder(result, latest) { result.Status = domain.SCUMObservationStale result.ErrorCode = "older_observation" result.SafeSummary = domain.SCUMSafeSummary{Title: "旧数据已忽略", Message: "Run 返回的 SCUM.db 数据早于当前本地记录,未覆盖 last-known-good 数据。"} } observation := domain.SCUMDataObservation{ID: scumObservationID(result), ServerInstanceID: result.ServerInstanceID, PluginID: result.PluginID, Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, Status: result.Status, ErrorCode: result.ErrorCode, SafeSummary: result.SafeSummary, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt} if err := svc.upsertSCUMObservation(observation); err != nil { return domain.SCUMDataObservation{}, err } if result.Status != domain.SCUMObservationAccepted { if result.Status == domain.SCUMObservationFailed { return observation, svc.markSCUMQueryStale(result, "observation_failed") } return observation, nil } freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: observation.ID, Source: observation.Source, QueryKey: observation.QueryKey, Sequence: observation.Sequence, Checksum: observation.Checksum, ObservedAt: observation.ObservedAt, ReceivedAt: observation.ReceivedAt} if err := svc.applySCUMRows(result.QueryKey, result.ServerInstanceID, result.Rows, freshness); err != nil { return domain.SCUMDataObservation{}, err } return observation, nil } func (svc *CoreService) ListSCUMPlayerLiveStatesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error) { if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { return nil, err } values, err := svc.store.SCUMPlayerLiveStates().List(filter) if err != nil { return nil, err } limitSCUMProjectionSlice(&values, filter.Limit) return values, nil } func (svc *CoreService) ListSCUMSquadsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error) { if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { return nil, err } values, err := svc.store.SCUMSquads().List(filter) if err != nil { return nil, err } limitSCUMProjectionSlice(&values, filter.Limit) return values, nil } func (svc *CoreService) ListSCUMSquadMembersForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error) { if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { return nil, err } values, err := svc.store.SCUMSquadMembers().List(filter) if err != nil { return nil, err } limitSCUMProjectionSlice(&values, filter.Limit) return values, nil } func (svc *CoreService) ListSCUMVehiclesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error) { if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { return nil, err } values, err := svc.store.SCUMVehicles().List(filter) if err != nil { return nil, err } limitSCUMProjectionSlice(&values, filter.Limit) return values, nil } func (svc *CoreService) ListSCUMFlagsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error) { if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { return nil, err } values, err := svc.store.SCUMFlags().List(filter) if err != nil { return nil, err } limitSCUMProjectionSlice(&values, filter.Limit) return values, nil } func (svc *CoreService) ListSCUMCurrentPositionsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error) { if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { return nil, err } values, err := svc.store.SCUMCurrentPositions().List(filter) if err != nil { return nil, err } limitSCUMProjectionSlice(&values, filter.Limit) return values, nil } func (svc *CoreService) latestSCUMObservation(serverID, pluginID, queryKey string) (domain.SCUMDataObservation, error) { observations, err := svc.store.SCUMDataObservations().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, QueryKey: queryKey}) if err != nil { return domain.SCUMDataObservation{}, err } var latest domain.SCUMDataObservation for _, observation := range observations { if pluginID != "" && observation.PluginID != pluginID { continue } if latest.ObservedAt.IsZero() || observation.Sequence > latest.Sequence || (observation.Sequence == latest.Sequence && observation.ObservedAt.After(latest.ObservedAt)) { latest = observation } } return latest, nil } func scumObservationOlder(next domain.SCUMObservationResult, latest domain.SCUMDataObservation) bool { if next.Sequence > 0 && latest.Sequence > 0 && next.Sequence <= latest.Sequence { return true } return !next.ObservedAt.IsZero() && !latest.ObservedAt.IsZero() && next.ObservedAt.Before(latest.ObservedAt) } func (svc *CoreService) upsertSCUMObservation(observation domain.SCUMDataObservation) error { if existing, err := svc.store.SCUMDataObservations().Get(observation.ID); err == nil { existing.Status = observation.Status existing.ErrorCode = observation.ErrorCode existing.SafeSummary = observation.SafeSummary existing.ReceivedAt = observation.ReceivedAt return svc.store.SCUMDataObservations().Update(existing) } else if err != repo.ErrNotFound { return err } return svc.store.SCUMDataObservations().Create(observation) } func (svc *CoreService) applySCUMRows(queryKey, serverID string, rows []map[string]any, freshness domain.SCUMProjectionFreshnessState) error { lower := strings.ToLower(queryKey) if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") { for _, row := range rows { if err := svc.applySCUMPlayerRow(serverID, row, freshness); err != nil { return err } } } if strings.Contains(lower, "squad-member") || strings.Contains(lower, "squad.member") || strings.Contains(lower, "member") { for _, row := range rows { if err := svc.applySCUMSquadMemberRow(serverID, row, freshness); err != nil { return err } } } else if strings.Contains(lower, "squad") { for _, row := range rows { if err := svc.applySCUMSquadRow(serverID, row, freshness); err != nil { return err } } } if strings.Contains(lower, "vehicle") { for _, row := range rows { if err := svc.applySCUMVehicleRow(serverID, row, freshness); err != nil { return err } } } if strings.Contains(lower, "flag") { for _, row := range rows { if err := svc.applySCUMFlagRow(serverID, row, freshness); err != nil { return err } } } if strings.Contains(lower, "position") || strings.Contains(lower, "coordinate") { for _, row := range rows { if err := svc.applySCUMPositionRow(serverID, row, freshness); err != nil { return err } } } return nil } func (svc *CoreService) applySCUMPlayerRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id") profileID := firstString(row, "userProfileId", "user_profile_id", "profileId") steamID := firstString(row, "steamId", "steam_id") name := firstString(row, "displayName", "name", "playerName") if gamePlayerID == "" && steamID != "" { gamePlayerID = steamID } if gamePlayerID == "" && profileID == "" { return nil } playerRecordID := "" if gamePlayerID != "" { playerRecordID = gamePlayerRecordID(serverID, gamePlayerID) if err := svc.upsertSCUMGamePlayer(serverID, playerRecordID, gamePlayerID, name, freshness.ObservedAt); err != nil { return err } } idSource := gamePlayerID if idSource == "" { idSource = "profile-" + profileID } id := scumProjectionID("player-live", serverID, idSource) state, err := svc.store.SCUMPlayerLiveStates().Get(id) if err == repo.ErrNotFound { state = domain.SCUMPlayerLiveState{ID: id, ServerInstanceID: serverID, GamePlayerRecordID: playerRecordID, GamePlayerID: gamePlayerID, UserProfileID: profileID, SteamID: steamID, DisplayName: name, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} } else if err != nil { return err } if isProjectionOlder(freshness, state.Freshness) { return nil } state.GamePlayerRecordID = coalesceString(playerRecordID, state.GamePlayerRecordID) state.GamePlayerID = coalesceString(gamePlayerID, state.GamePlayerID) state.UserProfileID = coalesceString(profileID, state.UserProfileID) state.SteamID = coalesceString(steamID, state.SteamID) state.DisplayName = coalesceString(name, state.DisplayName) state.SquadID = coalesceString(firstString(row, "squadId", "squad_id"), state.SquadID) state.SquadName = coalesceString(firstString(row, "squadName", "squad_name"), state.SquadName) if value, ok := firstFloat(row, "famePoints", "fame_points", "fame"); ok { state.FamePoints = value } if value, ok := firstFloat(row, "normalBalance", "currencyNormal", "money", "normal_balance"); ok { state.NormalBalance = value } if value, ok := firstFloat(row, "goldBalance", "currencyGold", "gold", "gold_balance"); ok { state.GoldBalance = value } if value, ok := firstBool(row, "online", "isOnline"); ok { state.Online = value } state.LastLoginAt = coalesceTime(firstTime(row, "lastLoginAt", "last_login_at"), state.LastLoginAt) state.LastLogoutAt = coalesceTime(firstTime(row, "lastLogoutAt", "last_logout_at"), state.LastLogoutAt) state.LastSaveTime = coalesceTime(firstTime(row, "lastSaveTime", "last_save_time"), state.LastSaveTime) if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectPlayer, gamePlayerID, row, freshness); ok { position.GamePlayerRecordID = playerRecordID position.GamePlayerID = gamePlayerID state.Position = position if err := svc.upsertSCUMPosition(position); err != nil { return err } } state.UnknownFields = unknownRowFields(row, "gamePlayerId", "playerId", "steamId", "steam_id", "userProfileId", "user_profile_id", "profileId", "displayName", "name", "playerName", "squadId", "squad_id", "squadName", "squad_name", "famePoints", "fame_points", "fame", "normalBalance", "currencyNormal", "money", "normal_balance", "goldBalance", "currencyGold", "gold", "gold_balance", "online", "isOnline", "lastLoginAt", "last_login_at", "lastLogoutAt", "last_logout_at", "lastSaveTime", "last_save_time", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion") state.Freshness = freshness state.UpdatedAt = svc.now() if err == repo.ErrNotFound { return svc.store.SCUMPlayerLiveStates().Create(state) } return svc.store.SCUMPlayerLiveStates().Update(state) } func (svc *CoreService) applySCUMSquadRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { squadID := firstString(row, "squadId", "squad_id", "id") if squadID == "" { return nil } id := scumProjectionID("squad", serverID, squadID) value, err := svc.store.SCUMSquads().Get(id) if err == repo.ErrNotFound { value = domain.SCUMSquad{ID: id, ServerInstanceID: serverID, SquadID: squadID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} } else if err != nil { return err } if isProjectionOlder(freshness, value.Freshness) { return nil } value.Name = coalesceString(firstString(row, "name", "squadName", "squad_name"), value.Name) value.LeaderProfileID = coalesceString(firstString(row, "leaderProfileId", "leader_profile_id"), value.LeaderProfileID) value.LeaderPlayerID = coalesceString(firstString(row, "leaderPlayerId", "leader_player_id", "leaderSteamId"), value.LeaderPlayerID) if memberCount, ok := firstInt(row, "memberCount", "member_count"); ok { value.MemberCount = memberCount } if score, ok := firstFloat(row, "score", "fame", "points"); ok { value.Score = score } value.UnknownFields = unknownRowFields(row, "squadId", "squad_id", "id", "name", "squadName", "squad_name", "leaderProfileId", "leader_profile_id", "leaderPlayerId", "leader_player_id", "leaderSteamId", "memberCount", "member_count", "score", "fame", "points") value.Freshness = freshness value.UpdatedAt = svc.now() if err == repo.ErrNotFound { return svc.store.SCUMSquads().Create(value) } return svc.store.SCUMSquads().Update(value) } func (svc *CoreService) applySCUMSquadMemberRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { squadID := firstString(row, "squadId", "squad_id") profileID := firstString(row, "userProfileId", "user_profile_id", "profileId") gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id") if squadID == "" || (profileID == "" && gamePlayerID == "") { return nil } playerRecordID := "" if gamePlayerID != "" { playerRecordID = gamePlayerRecordID(serverID, gamePlayerID) if err := svc.upsertSCUMGamePlayer(serverID, playerRecordID, gamePlayerID, firstString(row, "displayName", "name", "playerName"), freshness.ObservedAt); err != nil { return err } } id := scumProjectionID("squad-member", serverID, squadID+"/"+coalesceString(profileID, gamePlayerID)) value, err := svc.store.SCUMSquadMembers().Get(id) if err == repo.ErrNotFound { value = domain.SCUMSquadMember{ID: id, ServerInstanceID: serverID, SquadID: squadID, UserProfileID: profileID, GamePlayerRecordID: playerRecordID, GamePlayerID: gamePlayerID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} } else if err != nil { return err } if isProjectionOlder(freshness, value.Freshness) { return nil } value.UserProfileID = coalesceString(profileID, value.UserProfileID) value.GamePlayerRecordID = coalesceString(playerRecordID, value.GamePlayerRecordID) value.GamePlayerID = coalesceString(gamePlayerID, value.GamePlayerID) value.SteamID = coalesceString(firstString(row, "steamId", "steam_id"), value.SteamID) value.DisplayName = coalesceString(firstString(row, "displayName", "name", "playerName"), value.DisplayName) value.Rank = coalesceString(firstString(row, "rank", "role"), value.Rank) if isLeader, ok := firstBool(row, "isLeader", "leader"); ok { value.IsLeader = isLeader } value.JoinedAt = coalesceTime(firstTime(row, "joinedAt", "joined_at"), value.JoinedAt) value.UnknownFields = unknownRowFields(row, "squadId", "squad_id", "userProfileId", "user_profile_id", "profileId", "gamePlayerId", "playerId", "steamId", "steam_id", "displayName", "name", "playerName", "rank", "role", "isLeader", "leader", "joinedAt", "joined_at") value.Freshness = freshness value.UpdatedAt = svc.now() if err == repo.ErrNotFound { return svc.store.SCUMSquadMembers().Create(value) } return svc.store.SCUMSquadMembers().Update(value) } func (svc *CoreService) applySCUMVehicleRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { vehicleID := firstString(row, "vehicleId", "vehicle_id", "id") entityID := firstString(row, "entityId", "entity_id") if vehicleID == "" && entityID != "" { vehicleID = entityID } if vehicleID == "" { return nil } id := scumProjectionID("vehicle", serverID, vehicleID) value, err := svc.store.SCUMVehicles().Get(id) if err == repo.ErrNotFound { value = domain.SCUMVehicle{ID: id, ServerInstanceID: serverID, VehicleID: vehicleID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} } else if err != nil { return err } if isProjectionOlder(freshness, value.Freshness) { return nil } value.EntityID = coalesceString(entityID, value.EntityID) value.ClassName = coalesceString(firstString(row, "className", "class", "type"), value.ClassName) value.Label = coalesceString(firstString(row, "label", "vehicleName", "name"), value.Label) if value.Label == "" { value.Label = coalesceString(value.ClassName, "Unknown vehicle") } value.OwnerProfileID = coalesceString(firstString(row, "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id"), value.OwnerProfileID) value.OwnerPlayerID = coalesceString(firstString(row, "ownerPlayerId", "owner_player_id", "steamId", "steam_id"), value.OwnerPlayerID) value.SquadID = coalesceString(firstString(row, "squadId", "squad_id"), value.SquadID) if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectVehicle, vehicleID, row, freshness); ok { position.VehicleID = vehicleID position.EntityID = entityID value.Position = position if err := svc.upsertSCUMPosition(position); err != nil { return err } } value.UnknownFields = unknownRowFields(row, "vehicleId", "vehicle_id", "id", "entityId", "entity_id", "className", "class", "type", "label", "vehicleName", "name", "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id", "ownerPlayerId", "owner_player_id", "steamId", "steam_id", "squadId", "squad_id", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion") value.Freshness = freshness value.UpdatedAt = svc.now() if err == repo.ErrNotFound { return svc.store.SCUMVehicles().Create(value) } return svc.store.SCUMVehicles().Update(value) } func (svc *CoreService) applySCUMFlagRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { flagID := firstString(row, "flagId", "flag_id", "baseElementId", "base_element_id", "id") entityID := firstString(row, "entityId", "entity_id") if flagID == "" && entityID != "" { flagID = entityID } if flagID == "" { return nil } id := scumProjectionID("flag", serverID, flagID) value, err := svc.store.SCUMFlags().Get(id) if err == repo.ErrNotFound { value = domain.SCUMFlag{ID: id, ServerInstanceID: serverID, FlagID: flagID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} } else if err != nil { return err } if isProjectionOlder(freshness, value.Freshness) { return nil } value.EntityID = coalesceString(entityID, value.EntityID) value.OwnerProfileID = coalesceString(firstString(row, "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id"), value.OwnerProfileID) value.OwnerPlayerID = coalesceString(firstString(row, "ownerPlayerId", "owner_player_id", "steamId", "steam_id"), value.OwnerPlayerID) value.OwnerSquadID = coalesceString(firstString(row, "ownerSquadId", "owner_squad_id", "squadId", "squad_id"), value.OwnerSquadID) value.OwnerSquadName = coalesceString(firstString(row, "ownerSquadName", "owner_squad_name", "squadName", "squad_name"), value.OwnerSquadName) value.OwnershipConfidence = coalesceString(firstString(row, "ownershipConfidence", "ownership_confidence"), value.OwnershipConfidence) if value.OwnershipConfidence == "" { value.OwnershipConfidence = "unknown" } if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectFlag, flagID, row, freshness); ok { position.EntityID = entityID value.Position = position if err := svc.upsertSCUMPosition(position); err != nil { return err } } value.UnknownFields = unknownRowFields(row, "flagId", "flag_id", "baseElementId", "base_element_id", "id", "entityId", "entity_id", "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id", "ownerPlayerId", "owner_player_id", "steamId", "steam_id", "ownerSquadId", "owner_squad_id", "squadId", "squad_id", "ownerSquadName", "owner_squad_name", "squadName", "squad_name", "ownershipConfidence", "ownership_confidence", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion") value.Freshness = freshness value.UpdatedAt = svc.now() if err == repo.ErrNotFound { return svc.store.SCUMFlags().Create(value) } return svc.store.SCUMFlags().Update(value) } func (svc *CoreService) applySCUMPositionRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error { subjectType := domain.SCUMProjectionSubject(firstString(row, "subjectType", "subject_type")) if subjectType == "" { if firstString(row, "vehicleId", "vehicle_id") != "" { subjectType = domain.SCUMProjectionSubjectVehicle } else { subjectType = domain.SCUMProjectionSubjectPlayer } } subjectID := firstString(row, "subjectId", "subject_id", "gamePlayerId", "playerId", "vehicleId", "flagId", "entityId", "id") position, ok := scumPositionFromRow(serverID, subjectType, subjectID, row, freshness) if !ok { return nil } position.GamePlayerID = firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id") if position.GamePlayerID != "" { position.GamePlayerRecordID = gamePlayerRecordID(serverID, position.GamePlayerID) } position.VehicleID = firstString(row, "vehicleId", "vehicle_id") position.EntityID = firstString(row, "entityId", "entity_id") return svc.upsertSCUMPosition(position) } func (svc *CoreService) upsertSCUMGamePlayer(serverID, recordID, gamePlayerID, displayName string, observedAt time.Time) error { if gamePlayerID == "" || recordID == "" { return nil } if observedAt.IsZero() { observedAt = svc.now() } player, err := svc.store.GamePlayers().Get(recordID) if err == repo.ErrNotFound { return svc.store.GamePlayers().Create(domain.GamePlayer{ID: recordID, ServerInstanceID: serverID, GamePlayerID: gamePlayerID, DisplayName: displayName, FirstSeenAt: observedAt, LastSeenAt: observedAt, LastEventAt: observedAt, CreatedAt: svc.now(), UpdatedAt: svc.now()}) } if err != nil { return err } if observedAt.Before(player.LastEventAt) { return nil } player.DisplayName = coalesceString(displayName, player.DisplayName) player.LastSeenAt = maxTime(player.LastSeenAt, observedAt) player.LastEventAt = observedAt player.UpdatedAt = svc.now() return svc.store.GamePlayers().Update(player) } func (svc *CoreService) projectSCUMLoginLiveState(player domain.GamePlayer, batch domain.LogBatchIngest, entry domain.LogEntry, observedAt time.Time, online bool, reason string) error { if player.ID == "" || player.GamePlayerID == "" { return nil } freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: entryID(batch.LogStreamID, entry.Seq), Source: "login-log", QueryKey: strings.TrimSpace(entry.Fields["eventType"]), Sequence: entry.Seq, Checksum: validator.LogLineChecksum(entry.Line), ObservedAt: observedAt, ReceivedAt: svc.now()} id := scumProjectionID("player-live", player.ServerInstanceID, player.GamePlayerID) state, err := svc.store.SCUMPlayerLiveStates().Get(id) if err == repo.ErrNotFound { state = domain.SCUMPlayerLiveState{ID: id, ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, GamePlayerID: player.GamePlayerID, DisplayName: player.DisplayName, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()} } else if err != nil { return err } if isProjectionOlder(freshness, state.Freshness) { return nil } state.GamePlayerRecordID = player.ID state.GamePlayerID = player.GamePlayerID state.DisplayName = player.DisplayName state.Online = online if online { state.LastLoginAt = observedAt } else { state.LastLogoutAt = observedAt } state.Freshness = freshness if reason != "" { state.UnknownFields = domain.CopyGameClientBridgePayload(map[string]any{"lastLogoutReason": bounded(reason, 80)}) } state.UpdatedAt = svc.now() if err == repo.ErrNotFound { return svc.store.SCUMPlayerLiveStates().Create(state) } return svc.store.SCUMPlayerLiveStates().Update(state) } func (svc *CoreService) upsertSCUMPosition(position domain.SCUMCurrentPosition) error { existing, err := svc.store.SCUMCurrentPositions().Get(position.ID) if err == repo.ErrNotFound { position.CreatedAt = svc.now() position.UpdatedAt = svc.now() return svc.store.SCUMCurrentPositions().Create(position) } if err != nil { return err } if isProjectionOlder(position.Freshness, existing.Freshness) { return nil } position.CreatedAt = existing.CreatedAt position.UpdatedAt = svc.now() return svc.store.SCUMCurrentPositions().Update(position) } func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult, reason string) error { freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionStale, ObservationID: scumObservationID(result), Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, StaleReason: reason, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt} lower := strings.ToLower(result.QueryKey) if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") { values, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID}) if err != nil { return err } for _, value := range values { if !isProjectionOlder(freshness, value.Freshness) { value.Freshness = freshness value.UpdatedAt = svc.now() if err := svc.store.SCUMPlayerLiveStates().Update(value); err != nil { return err } } } } if strings.Contains(lower, "squad") { values, err := svc.store.SCUMSquads().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID}) if err != nil { return err } for _, value := range values { if !isProjectionOlder(freshness, value.Freshness) { value.Freshness = freshness value.UpdatedAt = svc.now() if err := svc.store.SCUMSquads().Update(value); err != nil { return err } } } } if strings.Contains(lower, "vehicle") { values, err := svc.store.SCUMVehicles().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID}) if err != nil { return err } for _, value := range values { if !isProjectionOlder(freshness, value.Freshness) { value.Freshness = freshness value.UpdatedAt = svc.now() if err := svc.store.SCUMVehicles().Update(value); err != nil { return err } } } } if strings.Contains(lower, "flag") { values, err := svc.store.SCUMFlags().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID}) if err != nil { return err } for _, value := range values { if !isProjectionOlder(freshness, value.Freshness) { value.Freshness = freshness value.UpdatedAt = svc.now() if err := svc.store.SCUMFlags().Update(value); err != nil { return err } } } } return nil } func scumPositionFromRow(serverID string, subjectType domain.SCUMProjectionSubject, subjectID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) (domain.SCUMCurrentPosition, bool) { x, hasX := firstFloat(row, "x", "worldX", "world_x", "locationX") y, hasY := firstFloat(row, "y", "worldY", "world_y", "locationY") z, hasZ := firstFloat(row, "z", "worldZ", "world_z", "locationZ") if !hasX || !hasY { return domain.SCUMCurrentPosition{}, false } if subjectID == "" { return domain.SCUMCurrentPosition{}, false } position := domain.SCUMCurrentPosition{ID: scumProjectionID("position-"+string(subjectType), serverID, subjectID), ServerInstanceID: serverID, SubjectType: subjectType, SubjectID: subjectID, MapID: coalesceString(firstString(row, "mapId", "map_id"), domain.SCUMMapTrajectoryMapID), MapVersion: coalesceString(firstString(row, "mapVersion", "map_version"), "0.9"), X: x, Y: y, HasCoordinates: true, LastSaveTime: firstTime(row, "lastSaveTime", "last_save_time"), Freshness: freshness} if hasZ && !math.IsNaN(z) { position.Z = z } return position, true } func isProjectionOlder(next, current domain.SCUMProjectionFreshnessState) bool { if current.Status == "" || current.Status == domain.SCUMProjectionUnknown { return false } if next.Source == current.Source && next.QueryKey == current.QueryKey && next.Sequence > 0 && current.Sequence > 0 && next.Sequence < current.Sequence { return true } return !next.ObservedAt.IsZero() && !current.ObservedAt.IsZero() && next.ObservedAt.Before(current.ObservedAt) } func scumObservationID(result domain.SCUMObservationResult) string { seed := fmt.Sprintf("%s/%s/%s/%d/%s", result.ServerInstanceID, result.PluginID, result.QueryKey, result.Sequence, result.Checksum) if result.Checksum == "" { seed = fmt.Sprintf("%s/%s/%s/%d/%s", result.ServerInstanceID, result.PluginID, result.QueryKey, result.Sequence, result.ObservedAt.Format(time.RFC3339Nano)) } return "scum-observation-" + fingerprintID(result.ServerInstanceID, seed) } func scumProjectionID(kind, serverID, subject string) string { return "scum-" + kind + "-" + fingerprintID(serverID, subject) } func firstString(row map[string]any, keys ...string) string { for _, key := range keys { if value, ok := row[key]; ok { switch typed := value.(type) { case string: if trimmed := strings.TrimSpace(typed); trimmed != "" { return trimmed } case fmt.Stringer: if trimmed := strings.TrimSpace(typed.String()); trimmed != "" { return trimmed } case int, int64, uint64, float64: return fmt.Sprint(typed) } } } return "" } func firstFloat(row map[string]any, keys ...string) (float64, bool) { for _, key := range keys { if value, ok := row[key]; ok { switch typed := value.(type) { case float64: return typed, true case float32: return float64(typed), true case int: return float64(typed), true case int64: return float64(typed), true case uint64: return float64(typed), true case string: parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) if err == nil { return parsed, true } } } } return 0, false } func firstInt(row map[string]any, keys ...string) (int, bool) { value, ok := firstFloat(row, keys...) if !ok { return 0, false } return int(value), true } func firstBool(row map[string]any, keys ...string) (bool, bool) { for _, key := range keys { if value, ok := row[key]; ok { switch typed := value.(type) { case bool: return typed, true case string: parsed, err := strconv.ParseBool(strings.TrimSpace(typed)) if err == nil { return parsed, true } case int: return typed != 0, true case int64: return typed != 0, true case float64: return typed != 0, true } } } return false, false } func firstTime(row map[string]any, keys ...string) time.Time { for _, key := range keys { if value, ok := row[key]; ok { switch typed := value.(type) { case time.Time: return typed case string: trimmed := strings.TrimSpace(typed) if trimmed == "" { continue } if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil { return parsed } if parsed, err := time.Parse("2006-01-02 15:04:05", trimmed); err == nil { return parsed.UTC() } case int64: return time.Unix(typed, 0).UTC() case float64: return time.Unix(int64(typed), 0).UTC() } } } return time.Time{} } func unknownRowFields(row map[string]any, known ...string) map[string]any { knownSet := map[string]struct{}{} for _, key := range known { knownSet[key] = struct{}{} } unknown := map[string]any{} for key, value := range row { if _, ok := knownSet[key]; ok { continue } unknown[key] = value } if len(unknown) == 0 { return nil } return domain.CopyGameClientBridgePayload(unknown) } func coalesceString(next, current string) string { if strings.TrimSpace(next) != "" { return strings.TrimSpace(next) } return current } func coalesceTime(next, current time.Time) time.Time { if !next.IsZero() { return next } return current } func limitSCUMProjectionSlice[T any](values *[]T, limit int) { if limit > 0 && len(*values) > limit { *values = (*values)[:limit] } }