Files
browser/platform/service/game_map_trajectories.go
T

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
}