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 }