317 lines
14 KiB
Go
317 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"math"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"browser.local/platform/domain"
|
|
"browser.local/platform/repo"
|
|
)
|
|
|
|
const maxMapTrajectoryWindow = 24 * time.Hour
|
|
const maxMapTrajectoryEntities = 20
|
|
const maxMapTrajectoryPointsPerEntity = 600
|
|
|
|
func (svc *CoreService) GetGameMapTrajectoriesForSession(sessionID string, query domain.GameMapTrajectoryQuery) (domain.GameMapTrajectoryView, error) {
|
|
query = domain.CopyGameMapTrajectoryQuery(query)
|
|
if err := svc.authorizeServerLifecycle(sessionID, query.ServerInstanceID); err != nil {
|
|
return domain.GameMapTrajectoryView{}, err
|
|
}
|
|
declaration, err := svc.mapTrajectoryDeclaration(query.ServerInstanceID)
|
|
if err != nil {
|
|
return domain.GameMapTrajectoryView{}, err
|
|
}
|
|
if declaration == nil {
|
|
return domain.GameMapTrajectoryView{Status: "missing-map", Reason: "此插件未声明兼容的 SCUM 地图、坐标转换或保留策略。"}, nil
|
|
}
|
|
if query.To.IsZero() {
|
|
query.To = svc.now()
|
|
}
|
|
if query.From.IsZero() {
|
|
query.From = query.To.Add(-time.Hour)
|
|
}
|
|
if query.From.After(query.To) || query.To.Sub(query.From) > maxMapTrajectoryWindow || len(query.PlayerRecordIDs) > maxMapTrajectoryEntities || len(query.VehicleIDs) > maxMapTrajectoryEntities {
|
|
return domain.GameMapTrajectoryView{}, validationError("map trajectory window or entity filters are invalid")
|
|
}
|
|
if err := svc.pruneGameMapTrajectories(query.ServerInstanceID); err != nil {
|
|
return domain.GameMapTrajectoryView{}, err
|
|
}
|
|
view := domain.GameMapTrajectoryView{Status: "ready", Map: *declaration, From: query.From, To: query.To, Players: []domain.GameMapTrajectoryEntity{}, Vehicles: []domain.GameMapTrajectoryEntity{}, RideSegments: []domain.GameMapTrajectorySegment{}}
|
|
for _, id := range uniqueBoundedIDs(query.PlayerRecordIDs) {
|
|
player, getErr := svc.store.GamePlayers().Get(id)
|
|
if getErr == repo.ErrNotFound || player.ServerInstanceID != query.ServerInstanceID {
|
|
continue
|
|
}
|
|
if getErr != nil {
|
|
return domain.GameMapTrajectoryView{}, getErr
|
|
}
|
|
points, listErr := svc.store.GameMapTrackPoints().List(domain.GameMapTrackPointFilter{ServerInstanceID: query.ServerInstanceID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, EntityKind: domain.GameMapTrackEntityPlayer, EntityID: player.GamePlayerID, OccurredAfter: query.From, OccurredBefore: query.To, Limit: maxMapTrajectoryPointsPerEntity})
|
|
if listErr != nil {
|
|
return domain.GameMapTrajectoryView{}, listErr
|
|
}
|
|
view.Players = append(view.Players, mapTrajectoryEntity(domain.GameMapTrackEntityPlayer, player.GamePlayerID, player.ID, player.DisplayName, points))
|
|
}
|
|
for _, id := range uniqueBoundedIDs(query.VehicleIDs) {
|
|
points, listErr := svc.store.GameMapTrackPoints().List(domain.GameMapTrackPointFilter{ServerInstanceID: query.ServerInstanceID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, EntityKind: domain.GameMapTrackEntityVehicle, EntityID: id, OccurredAfter: query.From, OccurredBefore: query.To, Limit: maxMapTrajectoryPointsPerEntity})
|
|
if listErr != nil {
|
|
return domain.GameMapTrajectoryView{}, listErr
|
|
}
|
|
if len(points) > 0 {
|
|
view.Vehicles = append(view.Vehicles, mapTrajectoryEntity(domain.GameMapTrackEntityVehicle, id, "", id, points))
|
|
}
|
|
}
|
|
segments, err := svc.store.GamePlayerVehicleSegments().List(domain.GamePlayerVehicleSegmentFilter{ServerInstanceID: query.ServerInstanceID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, OccurredAfter: query.From, OccurredBefore: query.To, Limit: 200})
|
|
if err != nil {
|
|
return domain.GameMapTrajectoryView{}, err
|
|
}
|
|
playerSet, vehicleSet := idSet(query.PlayerRecordIDs), idSet(query.VehicleIDs)
|
|
for _, segment := range segments {
|
|
if (len(playerSet) == 0 || playerSet[segment.GamePlayerRecordID]) && (len(vehicleSet) == 0 || vehicleSet[segment.VehicleID]) {
|
|
view.RideSegments = append(view.RideSegments, domain.GameMapTrajectorySegment{GamePlayerRecordID: segment.GamePlayerRecordID, VehicleID: segment.VehicleID, StartedAt: segment.StartedAt, EndedAt: segment.EndedAt})
|
|
}
|
|
}
|
|
if len(view.Players) == 0 && len(view.Vehicles) == 0 {
|
|
view.Status = "empty"
|
|
view.Reason = "所选时间窗内没有已采集且兼容当前地图版本的轨迹。"
|
|
}
|
|
return domain.CopyGameMapTrajectoryView(view), nil
|
|
}
|
|
|
|
func (svc *CoreService) projectGameMapTrajectoryEvents(batch domain.LogBatchIngest) error {
|
|
for _, entry := range batch.Entries {
|
|
if err := svc.projectGameMapTrajectoryEvent(batch, entry); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return svc.pruneGameMapTrajectories(batch.ServerInstanceID)
|
|
}
|
|
func (svc *CoreService) projectGameMapTrajectoryEvent(batch domain.LogBatchIngest, entry domain.LogEntry) error {
|
|
fields := entry.Fields
|
|
if fields == nil {
|
|
return nil
|
|
}
|
|
eventType := strings.TrimSpace(fields["eventType"])
|
|
if eventType != "player.position" && eventType != "vehicle.position" && eventType != "player.vehicle.enter" && eventType != "player.vehicle.leave" {
|
|
return nil
|
|
}
|
|
declaration, err := svc.mapTrajectoryDeclaration(batch.ServerInstanceID)
|
|
if err != nil || declaration == nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(fields["mapId"]) != declaration.MapID || strings.TrimSpace(fields["mapVersion"]) != declaration.MapVersion {
|
|
return nil
|
|
}
|
|
source := strings.TrimSpace(fields["source"])
|
|
if source != "companion" && source != "log-projection" {
|
|
return nil
|
|
}
|
|
occurred := mapEventTime(entry, fields, svc.now())
|
|
collected := mapCollectedTime(fields, svc.now())
|
|
eventID := "map-event-" + entryID(batch.LogStreamID, entry.Seq)
|
|
if eventType == "player.position" || eventType == "vehicle.position" {
|
|
return svc.projectGameMapPosition(batch.ServerInstanceID, eventID, eventType, fields, occurred, collected, source, *declaration)
|
|
}
|
|
return svc.projectGameMapVehicleTransition(batch.ServerInstanceID, eventID, eventType, fields, occurred, *declaration)
|
|
}
|
|
func (svc *CoreService) projectGameMapPosition(serverID, eventID, eventType string, fields map[string]string, occurred, collected time.Time, source string, declaration domain.GameMapTrajectoryDeclaration) error {
|
|
entityKind, entityID := domain.GameMapTrackEntityVehicle, strings.TrimSpace(fields["vehicleId"])
|
|
playerRecordID := ""
|
|
if eventType == "player.position" {
|
|
entityKind, entityID = domain.GameMapTrackEntityPlayer, strings.TrimSpace(fields["playerId"])
|
|
playerRecordID = gamePlayerRecordID(serverID, entityID)
|
|
}
|
|
if !mapTrajectoryID(entityID) {
|
|
return nil
|
|
}
|
|
x, okX := mapNumber(fields["worldX"])
|
|
y, okY := mapNumber(fields["worldY"])
|
|
if !okX || !okY {
|
|
return nil
|
|
}
|
|
mapX, mapY, ok := projectMapPoint(declaration, x, y)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
id := "map-point-" + fingerprintID(serverID, eventID)
|
|
if _, err := svc.store.GameMapTrackPoints().Get(id); err == nil {
|
|
return nil
|
|
} else if err != repo.ErrNotFound {
|
|
return err
|
|
}
|
|
existing, err := svc.store.GameMapTrackPoints().List(domain.GameMapTrackPointFilter{ServerInstanceID: serverID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, EntityKind: entityKind, EntityID: entityID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if compressedMapPoint(existing, occurred, mapX, mapY, declaration) {
|
|
return nil
|
|
}
|
|
return svc.store.GameMapTrackPoints().Create(domain.GameMapTrackPoint{ID: id, EventID: eventID, ServerInstanceID: serverID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, EntityKind: entityKind, EntityID: entityID, GamePlayerRecordID: playerRecordID, MapX: mapX, MapY: mapY, Source: source, OccurredAt: occurred, CollectedAt: collected, ExpiresAt: occurred.Add(time.Duration(declaration.RetentionSeconds) * time.Second)})
|
|
}
|
|
func (svc *CoreService) projectGameMapVehicleTransition(serverID, eventID, eventType string, fields map[string]string, occurred time.Time, declaration domain.GameMapTrajectoryDeclaration) error {
|
|
playerID, vehicleID := strings.TrimSpace(fields["playerId"]), strings.TrimSpace(fields["vehicleId"])
|
|
if !mapTrajectoryID(playerID) || !mapTrajectoryID(vehicleID) {
|
|
return nil
|
|
}
|
|
playerRecordID := gamePlayerRecordID(serverID, playerID)
|
|
id := "map-ride-" + fingerprintID(serverID, eventID)
|
|
if _, err := svc.store.GamePlayerVehicleSegments().Get(id); err == nil {
|
|
return nil
|
|
} else if err != repo.ErrNotFound {
|
|
return err
|
|
}
|
|
segments, err := svc.store.GamePlayerVehicleSegments().List(domain.GamePlayerVehicleSegmentFilter{ServerInstanceID: serverID, GamePlayerRecordID: playerRecordID, MapID: declaration.MapID, MapVersion: declaration.MapVersion})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, segment := range segments {
|
|
if segment.EndedAt.IsZero() && !segment.StartedAt.After(occurred) && (eventType == "player.vehicle.enter" || segment.VehicleID == vehicleID) {
|
|
segment.EndedAt = occurred
|
|
if err := svc.store.GamePlayerVehicleSegments().Update(segment); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if eventType == "player.vehicle.leave" {
|
|
return nil
|
|
}
|
|
return svc.store.GamePlayerVehicleSegments().Create(domain.GamePlayerVehicleSegment{ID: id, EventID: eventID, ServerInstanceID: serverID, GamePlayerRecordID: playerRecordID, GamePlayerID: playerID, VehicleID: vehicleID, MapID: declaration.MapID, MapVersion: declaration.MapVersion, StartedAt: occurred, ExpiresAt: occurred.Add(time.Duration(declaration.RetentionSeconds) * time.Second)})
|
|
}
|
|
func (svc *CoreService) pruneGameMapTrajectories(serverID string) error {
|
|
now := svc.now()
|
|
points, err := svc.store.GameMapTrackPoints().List(domain.GameMapTrackPointFilter{ServerInstanceID: serverID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, point := range points {
|
|
if !point.ExpiresAt.After(now) {
|
|
if err := svc.store.GameMapTrackPoints().Delete(point.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
segments, err := svc.store.GamePlayerVehicleSegments().List(domain.GamePlayerVehicleSegmentFilter{ServerInstanceID: serverID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, segment := range segments {
|
|
if !segment.ExpiresAt.After(now) {
|
|
if err := svc.store.GamePlayerVehicleSegments().Delete(segment.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func (svc *CoreService) mapTrajectoryDeclaration(serverID string) (*domain.GameMapTrajectoryDeclaration, error) {
|
|
instance, err := svc.store.ServerInstances().Get(serverID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if plugin.MapTrajectories == nil {
|
|
return nil, nil
|
|
}
|
|
declaration := domain.CopyGameMapTrajectoryDeclaration(*plugin.MapTrajectories)
|
|
if !validMapTrajectoryDeclaration(declaration) {
|
|
return nil, validationError("plugin map trajectory declaration is invalid")
|
|
}
|
|
return &declaration, nil
|
|
}
|
|
func validMapTrajectoryDeclaration(v domain.GameMapTrajectoryDeclaration) bool {
|
|
return v.MapID != "" && v.MapVersion != "" && v.WorldMaxX > v.WorldMinX && v.WorldMaxY > v.WorldMinY && v.ImageWidth > 0 && v.ImageHeight > 0 && v.Precision > 0 && v.SampleDistance >= 0 && v.SampleIntervalSeconds >= 0 && v.RetentionSeconds > 0 && v.RetentionSeconds <= 31*24*60*60
|
|
}
|
|
func projectMapPoint(v domain.GameMapTrajectoryDeclaration, x, y float64) (float64, float64, bool) {
|
|
if !finite(x) || !finite(y) || x < v.WorldMinX || x > v.WorldMaxX || y < v.WorldMinY || y > v.WorldMaxY {
|
|
return 0, 0, false
|
|
}
|
|
return roundMap((x-v.WorldMinX)/(v.WorldMaxX-v.WorldMinX)*1000, v.Precision), roundMap((y-v.WorldMinY)/(v.WorldMaxY-v.WorldMinY)*1000, v.Precision), true
|
|
}
|
|
func compressedMapPoint(points []domain.GameMapTrackPoint, occurred time.Time, x, y float64, declaration domain.GameMapTrajectoryDeclaration) bool {
|
|
var prior *domain.GameMapTrackPoint
|
|
for i := range points {
|
|
if !points[i].OccurredAt.After(occurred) && (prior == nil || points[i].OccurredAt.After(prior.OccurredAt)) {
|
|
prior = &points[i]
|
|
}
|
|
}
|
|
if prior == nil {
|
|
return false
|
|
}
|
|
seconds := occurred.Sub(prior.OccurredAt).Seconds()
|
|
distance := math.Hypot(x-prior.MapX, y-prior.MapY)
|
|
return seconds < float64(declaration.SampleIntervalSeconds) && distance < declaration.SampleDistance
|
|
}
|
|
func mapTrajectoryEntity(kind domain.GameMapTrackEntityKind, entityID, playerID, label string, points []domain.GameMapTrackPoint) domain.GameMapTrajectoryEntity {
|
|
sort.Slice(points, func(i, j int) bool { return points[i].OccurredAt.Before(points[j].OccurredAt) })
|
|
sources := map[string]struct{}{}
|
|
var collected time.Time
|
|
for _, point := range points {
|
|
sources[point.Source] = struct{}{}
|
|
if point.CollectedAt.After(collected) {
|
|
collected = point.CollectedAt
|
|
}
|
|
}
|
|
values := make([]string, 0, len(sources))
|
|
for source := range sources {
|
|
values = append(values, source)
|
|
}
|
|
sort.Strings(values)
|
|
return domain.GameMapTrajectoryEntity{Kind: kind, EntityID: entityID, GamePlayerRecordID: playerID, Label: label, Points: points, CollectedAt: collected, Sources: values}
|
|
}
|
|
func mapEventTime(entry domain.LogEntry, fields map[string]string, fallback time.Time) time.Time {
|
|
if value, err := time.Parse(time.RFC3339, strings.TrimSpace(fields["occurredAt"])); err == nil {
|
|
return value
|
|
}
|
|
if !entry.Timestamp.IsZero() {
|
|
return entry.Timestamp
|
|
}
|
|
return fallback
|
|
}
|
|
func mapCollectedTime(fields map[string]string, fallback time.Time) time.Time {
|
|
if value, err := time.Parse(time.RFC3339, strings.TrimSpace(fields["collectedAt"])); err == nil {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
func mapNumber(value string) (float64, bool) {
|
|
number, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
|
return number, err == nil && finite(number)
|
|
}
|
|
func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }
|
|
func roundMap(value, precision float64) float64 { return math.Round(value/precision) * precision }
|
|
func mapTrajectoryID(value string) bool {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || len(value) > 96 {
|
|
return false
|
|
}
|
|
for _, char := range value {
|
|
if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '-' || char == '_' || char == '.' || char == ':') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
func uniqueBoundedIDs(values []string) []string {
|
|
seen := map[string]bool{}
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if mapTrajectoryID(value) && !seen[value] {
|
|
seen[value] = true
|
|
result = append(result, value)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
func idSet(values []string) map[string]bool {
|
|
result := map[string]bool{}
|
|
for _, value := range uniqueBoundedIDs(values) {
|
|
result[value] = true
|
|
}
|
|
return result
|
|
}
|