diff --git a/internal/cmd/serve.go b/internal/cmd/serve.go index 03f580cf..08432cdb 100644 --- a/internal/cmd/serve.go +++ b/internal/cmd/serve.go @@ -285,7 +285,6 @@ func serveCmd() *cobra.Command { //nolint:maintidx appeals := appeal.NewAppealUsecase(appeal.NewAppealRepository(dbConn), banUsecase, personUsecase, notificationUsecase, configUsecase) matchRepo := match.NewMatchRepository(eventBroadcaster, dbConn, personUsecase, serversUC, notificationUsecase, stateUsecase, weaponsMap) - go matchRepo.Start(ctx) matchUsecase := match.NewMatchUsecase(matchRepo, stateUsecase, serversUC, notificationUsecase) diff --git a/internal/demo/demo_usecase.go b/internal/demo/demo_usecase.go index e0d52dfd..41b3c13f 100644 --- a/internal/demo/demo_usecase.go +++ b/internal/demo/demo_usecase.go @@ -296,7 +296,7 @@ func (d demoUsecase) CreateFromAsset(ctx context.Context, asset domain.Asset, se } for key := range demoDetail.State.Users { - intStats[key] = gin.H{} + intStats[fmt.Sprintf("%d", key)] = gin.H{} } timeStr := fmt.Sprintf("%s-%s", namePartsAll[0], namePartsAll[1]) diff --git a/internal/domain/demo.go b/internal/domain/demo.go index 91c3b558..26e9f533 100644 --- a/internal/domain/demo.go +++ b/internal/domain/demo.go @@ -2,6 +2,8 @@ package domain import ( "context" + "github.com/leighmacdonald/gbans/pkg/logparse" + "github.com/leighmacdonald/steamid/v4/steamid" "time" "github.com/gin-gonic/gin" @@ -54,6 +56,8 @@ type DemoFile struct { AssetID uuid.UUID `json:"asset_id"` } +const DemoType = "HL2DEMO" + type DemoInfo struct { DemoID int64 Title string @@ -61,11 +65,11 @@ type DemoInfo struct { } type DemoPlayer struct { - Classes struct{} `json:"classes"` - Name string `json:"name"` - UserID int `json:"userId"` //nolint:tagliatelle - SteamID string `json:"steamId"` //nolint:tagliatelle - Team string `json:"team"` + Classes map[logparse.PlayerClass]int `json:"classes"` + Name string `json:"name"` + UserID int `json:"userId"` //nolint:tagliatelle + SteamID steamid.SteamID `json:"steamId"` //nolint:tagliatelle + Team logparse.Team `json:"team"` } type DemoHeader struct { @@ -82,10 +86,57 @@ type DemoHeader struct { Signon int `json:"signon"` } +type DemoWeaponDetail struct { + Kills int `json:"kills"` + Damage int `json:"damage"` + Shots int `json:"shots"` + Hits int `json:"hits"` + Backstabs int `json:"backstabs,` + Headshots int `json:"headshots"` + Airshots int `json:"airshots"` +} + +type DemoPlayerSummaries struct { + Points int `json:"points"` + Kills int `json:"kills"` + Assists int `json:"assists"` + Deaths int `json:"deaths"` + BuildingsDestroyed int `json:"buildings_destroyed"` + Captures int `json:"captures"` + Defenses int `json:"defenses"` + Dominations int `json:"dominations"` + Revenges int `json:"revenges"` + Ubercharges int `json:"ubercharges"` + Headshots int `json:"headshots"` + Teleports int `json:"teleports"` + Healing int `json:"healing"` + Backstabs int `json:"backstabs"` + BonusPoints int `json:"bonus_points"` + Support int `json:"support"` + DamgageDealt int `json:"damgage_dealt"` + WeaponMap map[logparse.Weapon]DemoWeaponDetail `json:"weapon_map"` +} + +type DemoChatMessage struct { +} + +type DemoMatchSummary struct { + ScoreBlu int `json:"score_blu"` + ScoreRed int `json:"score_red"` + Chat []DemoChatMessage `json:"chat"` +} + +type DemoRoundSummary struct { +} + +type DemoState struct { + DemoPlayerSummaries map[int]DemoPlayerSummaries `json:"player_summaries"` //nolint:tagliatelle + Users map[int]DemoPlayer `json:"users"` + DemoMatchSummary DemoMatchSummary `json:"match_summary"` //nolint:tagliatelle + DemoRoundSummary DemoRoundSummary `json:"round_summary"` +} + type DemoDetails struct { - State struct { - PlayerSummaries struct{} `json:"player_summaries"` - Users map[string]DemoPlayer `json:"users"` - } `json:"state"` + State DemoState `json:"state"` Header DemoHeader `json:"header"` } diff --git a/internal/domain/match.go b/internal/domain/match.go index cb0b8c35..5f0cb23d 100644 --- a/internal/domain/match.go +++ b/internal/domain/match.go @@ -13,25 +13,7 @@ import ( "golang.org/x/exp/slices" ) -type MatchTriggerType int - -const ( - MatchTriggerStart MatchTriggerType = 1 - MatchTriggerEnd MatchTriggerType = 2 -) - -type MatchTrigger struct { - Type MatchTriggerType - UUID uuid.UUID - Server Server - MapName string - DemoName string -} - type MatchRepository interface { - Start(ctx context.Context) - StartMatch(startTrigger MatchTrigger) - EndMatch(endTrigger MatchTrigger) Matches(ctx context.Context, opts MatchesQueryOpts) ([]MatchSummary, int64, error) MatchGetByID(ctx context.Context, matchID uuid.UUID, match *MatchResult) error MatchSave(ctx context.Context, match *logparse.Match, weaponMap fp.MutexMap[logparse.Weapon, int]) error @@ -56,8 +38,7 @@ type MatchRepository interface { GetMatchIDFromServerID(serverID int) (uuid.UUID, bool) } type MatchUsecase interface { - StartMatch(server Server, mapName string, demoName string) (uuid.UUID, error) - EndMatch(ctx context.Context, serverID int) (uuid.UUID, error) + CreateFromDemo(ctx context.Context, serverID int, details DemoDetails) (MatchSummary, error) GetMatchIDFromServerID(serverID int) (uuid.UUID, bool) Matches(ctx context.Context, opts MatchesQueryOpts) ([]MatchSummary, int64, error) MatchGetByID(ctx context.Context, matchID uuid.UUID, match *MatchResult) error @@ -443,7 +424,7 @@ type PlayerMedicStats struct { type CommonPlayerStats struct { SteamID steamid.SteamID `json:"steam_id"` Name string `json:"name"` - AvatarHash string `json:"avatar_hash"` + AvatarHash string `json:"avatar_hash"` //todo make Kills int `json:"kills"` Assists int `json:"assists"` Deaths int `json:"deaths"` diff --git a/internal/match/match_repository.go b/internal/match/match_repository.go index a90486d4..7c729941 100644 --- a/internal/match/match_repository.go +++ b/internal/match/match_repository.go @@ -8,7 +8,6 @@ import ( "github.com/gofrs/uuid/v5" "github.com/jackc/pgx/v5" "github.com/leighmacdonald/gbans/internal/database" - "github.com/leighmacdonald/gbans/internal/discord" "github.com/leighmacdonald/gbans/internal/domain" "github.com/leighmacdonald/gbans/pkg/fp" "github.com/leighmacdonald/gbans/pkg/logparse" @@ -21,7 +20,6 @@ type matchRepository struct { notifications domain.NotificationUsecase servers domain.ServersUsecase state domain.StateUsecase - summarizer *Summarizer wm fp.MutexMap[logparse.Weapon, int] events chan logparse.ServerEvent broadcaster *fp.Broadcaster[logparse.EventType, logparse.ServerEvent] @@ -44,69 +42,9 @@ func NewMatchRepository(broadcaster *fp.Broadcaster[logparse.EventType, logparse events: make(chan logparse.ServerEvent), } - matchRepo.summarizer = newMatchSummarizer(matchRepo.events, matchRepo.onMatchComplete) - return matchRepo } -func (r *matchRepository) StartMatch(startTrigger domain.MatchTrigger) { - r.summarizer.triggers <- startTrigger -} - -func (r *matchRepository) EndMatch(endTrigger domain.MatchTrigger) { - r.summarizer.triggers <- endTrigger -} - -func (r *matchRepository) onMatchComplete(ctx context.Context, matchContext *activeMatchContext) error { - const minPlayers = 6 - - server, found := r.state.ByServerID(matchContext.server.ServerID) - - if found && server.Name != "" { - matchContext.match.Title = server.Name - } - - fullServer, err := r.servers.Server(ctx, server.ServerID) - if err != nil { - return errors.Join(err, domain.ErrLoadServer) - } - - if !fullServer.EnableStats { - return nil - } - - if len(matchContext.match.PlayerSums) < minPlayers { - return domain.ErrInsufficientPlayers - } - - if matchContext.match.TimeStart == nil || matchContext.match.MapName == "" { - return domain.ErrIncompleteMatch - } - - if errSave := r.MatchSave(ctx, &matchContext.match, r.wm); errSave != nil { - if errors.Is(errSave, domain.ErrInsufficientPlayers) { - return domain.ErrInsufficientPlayers - } - - return errors.Join(errSave, domain.ErrSaveMatch) - } - - var result domain.MatchResult - if errResult := r.MatchGetByID(ctx, matchContext.match.MatchID, &result); errResult != nil { - return errors.Join(errResult, domain.ErrLoadMatch) - } - - r.notifications.Enqueue(ctx, domain.NewDiscordNotification( - domain.ChannelPublicMatchLog, - discord.MatchMessage(result, ""))) - - return nil -} - -func (r *matchRepository) Start(ctx context.Context) { - r.summarizer.Start(ctx) -} - func (r *matchRepository) GetMatchIDFromServerID(serverID int) (uuid.UUID, bool) { return r.matchUUIDMap.Get(serverID) } diff --git a/internal/match/match_service.go b/internal/match/match_service.go index 42e8da1f..1838d903 100644 --- a/internal/match/match_service.go +++ b/internal/match/match_service.go @@ -8,7 +8,6 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/gofrs/uuid/v5" "github.com/leighmacdonald/gbans/internal/domain" "github.com/leighmacdonald/gbans/internal/httphelper" "github.com/leighmacdonald/gbans/pkg/log" @@ -41,78 +40,6 @@ func NewMatchHandler(ctx context.Context, engine *gin.Engine, matches domain.Mat authed.GET("/api/stats/player/:steam_id/weapons", handler.onAPIGetPlayerWeaponStatsOverall()) authed.GET("/api/stats/player/:steam_id/classes", handler.onAPIGetPlayerClassStatsOverall()) authed.GET("/api/stats/player/:steam_id/overall", handler.onAPIGetPlayerStatsOverall()) - authed.POST("/api/sm/match/start", handler.onAPIPostMatchStart()) - authed.GET("/api/sm/match/end", handler.onAPIPostMatchEnd()) - } -} - -func (h matchHandler) onAPIPostMatchEnd() gin.HandlerFunc { - type endMatchResponse struct { - URL string `json:"url"` - } - - return func(ctx *gin.Context) { - serverID, errServerID := httphelper.GetIntParam(ctx, "server_id") - if errServerID != nil { - httphelper.HandleErrInternal(ctx) - slog.Warn("Failed to get server_id", log.ErrAttr(errServerID)) - - return - } - - matchUUID, errEnd := h.matches.EndMatch(ctx, serverID) - if errEnd != nil { - httphelper.ResponseAPIErr(ctx, http.StatusInternalServerError, domain.ErrUnknownServerID) - slog.Error("Failed to end match", log.ErrAttr(errEnd)) - - return - } - - ctx.JSON(http.StatusOK, endMatchResponse{URL: h.config.ExtURLRaw("/match/%s", matchUUID.String())}) - } -} - -func (h matchHandler) onAPIPostMatchStart() gin.HandlerFunc { - type matchStartRequest struct { - MapName string `json:"map_name"` - DemoName string `json:"demo_name"` - } - - type matchStartResponse struct { - MatchID uuid.UUID `json:"match_id"` - } - - return func(ctx *gin.Context) { - var req matchStartRequest - if !httphelper.Bind(ctx, &req) { - return - } - - serverID, errServerID := httphelper.GetIntParam(ctx, "server_id") - if errServerID != nil { - httphelper.ResponseAPIErr(ctx, http.StatusInternalServerError, domain.ErrUnknownServerID) - slog.Warn("Failed to get server_id", log.ErrAttr(errServerID)) - - return - } - - server, errServer := h.servers.Server(ctx, serverID) - if errServer != nil { - httphelper.ResponseAPIErr(ctx, http.StatusInternalServerError, domain.ErrUnknownServerID) - slog.Error("Failed to get server", log.ErrAttr(errServer)) - - return - } - - matchUUID, errMatch := h.matches.StartMatch(server, req.MapName, req.DemoName) - if errMatch != nil { - httphelper.ResponseAPIErr(ctx, http.StatusInternalServerError, domain.ErrUnknownServerID) - slog.Error("Failed to start match", log.ErrAttr(errMatch)) - - return - } - - ctx.JSON(http.StatusOK, matchStartResponse{MatchID: matchUUID}) } } diff --git a/internal/match/match_usecase.go b/internal/match/match_usecase.go index aaea8f8b..28db3613 100644 --- a/internal/match/match_usecase.go +++ b/internal/match/match_usecase.go @@ -3,6 +3,9 @@ package match import ( "context" "errors" + "math" + "strings" + "time" "github.com/gofrs/uuid/v5" "github.com/leighmacdonald/gbans/internal/domain" @@ -11,6 +14,15 @@ import ( "github.com/leighmacdonald/steamid/v4/steamid" ) +func parseMapName(name string) string { + if strings.HasPrefix(name, "workshop/") { + parts := strings.Split(strings.TrimPrefix(name, "workshop/"), ".ugc") + name = parts[0] + } + + return name +} + type matchUsecase struct { repository domain.MatchRepository state domain.StateUsecase @@ -29,43 +41,32 @@ func NewMatchUsecase(repository domain.MatchRepository, state domain.StateUsecas } } -func (m matchUsecase) StartMatch(server domain.Server, mapName string, demoName string) (uuid.UUID, error) { - matchUUID, errUUID := uuid.NewV4() - if errUUID != nil { - return uuid.UUID{}, errors.Join(errUUID, domain.ErrUUIDCreate) +func (m matchUsecase) CreateFromDemo(ctx context.Context, serverID int, details domain.DemoDetails) (domain.MatchSummary, error) { + server, errServer := m.servers.Server(context.Background(), serverID) + if errServer != nil { + return domain.MatchSummary{}, errServer } - - trigger := domain.MatchTrigger{ - Type: domain.MatchTriggerStart, - UUID: matchUUID, - Server: server, - MapName: mapName, - DemoName: demoName, + newID, errID := uuid.NewV4() + if errID != nil { + return domain.MatchSummary{}, errors.Join(errID, domain.ErrUUIDCreate) } - m.repository.StartMatch(trigger) - - return matchUUID, nil -} - -func (m matchUsecase) EndMatch(ctx context.Context, serverID int) (uuid.UUID, error) { - matchID, found := m.repository.GetMatchIDFromServerID(serverID) - if !found { - return matchID, domain.ErrLoadMatch - } - - server, errServer := m.servers.Server(ctx, serverID) - if errServer != nil { - return matchID, errors.Join(errServer, domain.ErrUnknownServer) + endTime := time.Now() + startTime := endTime.Add(-time.Duration(int(math.Ceil(details.Header.Duration)))) + s := domain.MatchSummary{ + MatchID: newID, + ServerID: server.ServerID, + IsWinner: false, + ShortName: server.ShortName, + Title: details.Header.Server, + MapName: details.Header.Map, + ScoreBlu: 0, + ScoreRed: 0, + TimeStart: startTime, + TimeEnd: endTime, } - m.repository.EndMatch(domain.MatchTrigger{ - Type: domain.MatchTriggerEnd, - UUID: matchID, - Server: server, - }) - - return matchID, nil + return s, nil } func (m matchUsecase) GetMatchIDFromServerID(serverID int) (uuid.UUID, bool) { diff --git a/internal/match/summarizer.go b/internal/match/summarizer.go deleted file mode 100644 index 9845cffc..00000000 --- a/internal/match/summarizer.go +++ /dev/null @@ -1,113 +0,0 @@ -package match - -import ( - "context" - "errors" - "log/slog" - "strings" - - "github.com/gofrs/uuid/v5" - "github.com/leighmacdonald/gbans/internal/domain" - "github.com/leighmacdonald/gbans/pkg/fp" - "github.com/leighmacdonald/gbans/pkg/log" - "github.com/leighmacdonald/gbans/pkg/logparse" -) - -// activeMatchContext controls and represents the broader match context including extra metadata. -type activeMatchContext struct { - match logparse.Match - cancel context.CancelFunc - finalScores int - server domain.Server -} - -type OnCompleteFn func(ctx context.Context, m *activeMatchContext) error - -type Summarizer struct { - uuidMap fp.MutexMap[int, uuid.UUID] - triggers chan domain.MatchTrigger - log *slog.Logger - eventChan chan logparse.ServerEvent - onComplete OnCompleteFn -} - -func newMatchSummarizer(eventChan chan logparse.ServerEvent, onComplete OnCompleteFn) *Summarizer { - return &Summarizer{ - uuidMap: fp.NewMutexMap[int, uuid.UUID](), - triggers: make(chan domain.MatchTrigger), - eventChan: eventChan, - onComplete: onComplete, - } -} - -func parseMapName(name string) string { - if strings.HasPrefix(name, "workshop/") { - parts := strings.Split(strings.TrimPrefix(name, "workshop/"), ".ugc") - name = parts[0] - } - - return name -} - -func (mh *Summarizer) Start(ctx context.Context) { - matches := map[int]*activeMatchContext{} - - for { - select { - case trigger := <-mh.triggers: - switch trigger.Type { - case domain.MatchTriggerStart: - match := logparse.NewMatch(trigger.Server.ServerID, trigger.Server.Name) - match.MapName = parseMapName(trigger.MapName) - match.DemoName = trigger.DemoName - - matchContext := &activeMatchContext{ - match: match, - server: trigger.Server, - } - - mh.uuidMap.Set(trigger.Server.ServerID, trigger.UUID) - - matches[trigger.Server.ServerID] = matchContext - case domain.MatchTriggerEnd: - matchContext, exists := matches[trigger.Server.ServerID] - if !exists { - return - } - - // Stop the incoming event handler - matchContext.cancel() - - if matchContext.server.EnableStats { - if errSave := mh.onComplete(ctx, matchContext); errSave != nil { - mh.log.Error("Failed to save match data", - slog.Int("server", matchContext.server.ServerID), log.ErrAttr(errSave)) - } - } - - delete(matches, trigger.Server.ServerID) - } - case evt := <-mh.eventChan: - matchContext, exists := matches[evt.ServerID] - if !exists { - // Discord any events w/o an existing match - continue - } - - if errApply := matchContext.match.Apply(evt.Results); errApply != nil && !errors.Is(errApply, logparse.ErrIgnored) { - slog.Error("Error applying event", - slog.String("server", evt.ServerName), - log.ErrAttr(errApply)) - } - - if evt.EventType == logparse.WTeamFinalScore { - matchContext.finalScores++ - if matchContext.finalScores < 2 { - continue - } - } - case <-ctx.Done(): - return - } - } -} diff --git a/internal/test/match_test.go b/internal/test/match_test.go new file mode 100644 index 00000000..584bd20a --- /dev/null +++ b/internal/test/match_test.go @@ -0,0 +1,84 @@ +package test + +import ( + "fmt" + "github.com/leighmacdonald/gbans/internal/domain" + "github.com/leighmacdonald/gbans/pkg/logparse" + "github.com/leighmacdonald/gbans/pkg/stringutil" + "github.com/leighmacdonald/steamid/v4/steamid" + "golang.org/x/exp/rand" +) + +func genMatch(players int) domain.DemoDetails { + s := domain.DemoState{ + DemoPlayerSummaries: make(map[int]domain.DemoPlayerSummaries), + Users: make(map[int]domain.DemoPlayer), + } + weaponIdx := 1 + for i := range players { + team := logparse.BLU + if i%2 == 0 { + team = logparse.RED + } + + s.Users[i+1] = domain.DemoPlayer{ + Classes: nil, + Name: stringutil.SecureRandomString(10), + UserID: i, + SteamID: steamid.RandSID64(), + Team: team, + } + + w := make(map[logparse.Weapon]domain.DemoWeaponDetail) + + for range int(rand.Int31n(5) + 1) { + weaponIdx++ + w[logparse.Weapon(rune(weaponIdx))] = domain.DemoWeaponDetail{ + Kills: int(rand.Int31n(200)), + Hits: int(rand.Int31n(200)), + Damage: int(rand.Int31n(30000)), + Shots: int(rand.Int31n(500)), + } + } + + s.DemoPlayerSummaries[i+1] = domain.DemoPlayerSummaries{ + Points: int(rand.Int31n(200)), + Kills: int(rand.Int31n(200)), + Assists: int(rand.Int31n(200)), + Deaths: int(rand.Int31n(200)), + BuildingsDestroyed: int(rand.Int31n(20)), + Captures: int(rand.Int31n(20)), + Defenses: int(rand.Int31n(20)), + Dominations: int(rand.Int31n(20)), + Revenges: int(rand.Int31n(20)), + Ubercharges: int(rand.Int31n(20)), + Headshots: int(rand.Int31n(20)), + Teleports: int(rand.Int31n(20)), + Healing: int(rand.Int31n(20)), + Backstabs: int(rand.Int31n(50000)), + BonusPoints: int(rand.Int31n(2000)), + Support: int(rand.Int31n(20000)), + DamgageDealt: int(rand.Int31n(50000)), + WeaponMap: w, + } + } + + d := domain.DemoDetails{ + State: s, + Header: domain.DemoHeader{ + DemoType: domain.DemoType, + Version: 3, + Protocol: 24, + Server: fmt.Sprintf("Test server: %s", stringutil.SecureRandomString(5)), + Nick: "SourceTV Demo", + Map: "pl_test", + Game: "tf", + Duration: float64(rand.Int31n(5000)), + Ticks: int(rand.Int31n(250000)), + Frames: int(rand.Int31n(25000)), + Signon: int(rand.Int31n(1000000)), + }, + } + + return d +} diff --git a/pkg/logparse/consts.go b/pkg/logparse/consts.go index 8bd9c0aa..41d5a190 100644 --- a/pkg/logparse/consts.go +++ b/pkg/logparse/consts.go @@ -154,14 +154,14 @@ type PlayerClass int const ( Spectator PlayerClass = iota Scout + Sniper Soldier - Pyro Demo - Heavy - Engineer Medic - Sniper + Heavy + Pyro Spy + Engineer Multi )