From 3a96a702ca5d5bcfec72a6ac8c211e7d54fc61e7 Mon Sep 17 00:00:00 2001 From: Leigh MacDonald Date: Sat, 28 Dec 2024 18:17:51 -0700 Subject: [PATCH] Create demoparse pkg --- internal/demo/demo_usecase.go | 75 +------------- internal/discord/discord_service.go | 9 +- internal/domain/demo.go | 80 --------------- internal/domain/errors.go | 1 - internal/domain/match.go | 39 +++---- internal/match/match_repository.go | 24 +++-- internal/match/match_usecase.go | 101 +++++++++++++----- internal/test/demos_test.go | 3 +- internal/test/match_test.go | 99 +++++++++++------- pkg/demoparse/consts.go | 154 ++++++++++++++++++++++++++++ pkg/demoparse/demo.go | 98 ++++++++++++++++++ pkg/demoparse/service.go | 77 ++++++++++++++ 12 files changed, 515 insertions(+), 245 deletions(-) create mode 100644 pkg/demoparse/consts.go create mode 100644 pkg/demoparse/demo.go create mode 100644 pkg/demoparse/service.go diff --git a/internal/demo/demo_usecase.go b/internal/demo/demo_usecase.go index 41b3c13f..ee4725de 100644 --- a/internal/demo/demo_usecase.go +++ b/internal/demo/demo_usecase.go @@ -1,22 +1,18 @@ package demo import ( - "bytes" "context" - "encoding/json" "errors" "fmt" - "io" "log/slog" - "mime/multipart" - "net/http" - "os" + "strconv" "strings" "time" "github.com/dustin/go-humanize" "github.com/gin-gonic/gin" "github.com/leighmacdonald/gbans/internal/domain" + "github.com/leighmacdonald/gbans/pkg/demoparse" "github.com/leighmacdonald/gbans/pkg/fs" "github.com/leighmacdonald/gbans/pkg/log" "github.com/ricochet2200/go-disk-usage/du" @@ -205,69 +201,6 @@ func (d demoUsecase) GetDemos(ctx context.Context) ([]domain.DemoFile, error) { return d.repository.GetDemos(ctx) } -func (d demoUsecase) SendAndParseDemo(ctx context.Context, path string) (*domain.DemoDetails, error) { - fileHandle, errDF := os.Open(path) - if errDF != nil { - return nil, errors.Join(errDF, domain.ErrDemoLoad) - } - - content, errContent := io.ReadAll(fileHandle) - if errContent != nil { - return nil, errors.Join(errDF, domain.ErrDemoLoad) - } - - info, errInfo := fileHandle.Stat() - if errInfo != nil { - return nil, errors.Join(errInfo, domain.ErrDemoLoad) - } - - log.Closer(fileHandle) - - body := new(bytes.Buffer) - writer := multipart.NewWriter(body) - - part, errCreate := writer.CreateFormFile("file", info.Name()) - if errCreate != nil { - return nil, errors.Join(errCreate, domain.ErrDemoLoad) - } - - if _, err := part.Write(content); err != nil { - return nil, errors.Join(errCreate, domain.ErrDemoLoad) - } - - if errClose := writer.Close(); errClose != nil { - return nil, errors.Join(errClose, domain.ErrDemoLoad) - } - - req, errReq := http.NewRequestWithContext(ctx, http.MethodPost, d.config.Config().Demo.DemoParserURL, body) - if errReq != nil { - return nil, errors.Join(errReq, domain.ErrDemoLoad) - } - req.Header.Set("Content-Type", writer.FormDataContentType()) - - client := &http.Client{} - resp, errSend := client.Do(req) - if errSend != nil { - return nil, errors.Join(errSend, domain.ErrDemoLoad) - } - - defer resp.Body.Close() - - var demo domain.DemoDetails - - // TODO remove this extra copy once this feature doesnt have much need for debugging/inspection. - rawBody, errRead := io.ReadAll(resp.Body) - if errRead != nil { - return nil, errors.Join(errRead, domain.ErrDemoLoad) - } - - if errDecode := json.NewDecoder(bytes.NewReader(rawBody)).Decode(&demo); errDecode != nil { - return nil, errors.Join(errDecode, domain.ErrDemoLoad) - } - - return &demo, nil -} - func (d demoUsecase) CreateFromAsset(ctx context.Context, asset domain.Asset, serverID int) (*domain.DemoFile, error) { _, errGetServer := d.servers.Server(ctx, serverID) if errGetServer != nil { @@ -290,13 +223,13 @@ func (d demoUsecase) CreateFromAsset(ctx context.Context, asset domain.Asset, se // TODO change this data shape as we have not needed this in a long time. Only keys the are used. intStats := map[string]gin.H{} - demoDetail, errDetail := d.SendAndParseDemo(ctx, asset.LocalPath) + demoDetail, errDetail := demoparse.Submit(ctx, d.config.Config().Demo.DemoParserURL, asset.LocalPath) if errDetail != nil { return nil, errDetail } for key := range demoDetail.State.Users { - intStats[fmt.Sprintf("%d", key)] = gin.H{} + intStats[strconv.Itoa(key)] = gin.H{} } timeStr := fmt.Sprintf("%s-%s", namePartsAll[0], namePartsAll[1]) diff --git a/internal/discord/discord_service.go b/internal/discord/discord_service.go index 99644b6c..7df5cfa5 100644 --- a/internal/discord/discord_service.go +++ b/internal/discord/discord_service.go @@ -614,12 +614,15 @@ func (h discordService) makeOnLogs() func(context.Context, *discordgo.Session, * for _, match := range matches { status := ":x:" - if match.IsWinner { - status = ":white_check_mark:" + + for _, player := range match.Players { + if player.SteamID == author.SteamID && match.Winner == player.Team { + status = ":white_check_mark:" + } } _, _ = matchesWriter.WriteString(fmt.Sprintf("%s [%s](%s) `%s` `%s`\n", - status, match.Title, h.config.ExtURL(match), match.MapName, match.TimeStart.Format(time.DateOnly))) + status, match.Title, h.config.ExtURLRaw("/match/%s", match.MatchID.String()), match.MapName, match.TimeStart.Format(time.DateOnly))) } return LogsMessage(count, matchesWriter.String()), nil diff --git a/internal/domain/demo.go b/internal/domain/demo.go index 26e9f533..b2f6ce72 100644 --- a/internal/domain/demo.go +++ b/internal/domain/demo.go @@ -2,8 +2,6 @@ package domain import ( "context" - "github.com/leighmacdonald/gbans/pkg/logparse" - "github.com/leighmacdonald/steamid/v4/steamid" "time" "github.com/gin-gonic/gin" @@ -18,7 +16,6 @@ type DemoUsecase interface { GetDemos(ctx context.Context) ([]DemoFile, error) CreateFromAsset(ctx context.Context, asset Asset, serverID int) (*DemoFile, error) Cleanup(ctx context.Context) - SendAndParseDemo(ctx context.Context, path string) (*DemoDetails, error) } type DemoRepository interface { @@ -63,80 +60,3 @@ type DemoInfo struct { Title string AssetID uuid.UUID } - -type DemoPlayer struct { - 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 { - DemoType string `json:"demo_type"` - Version int `json:"version"` - Protocol int `json:"protocol"` - Server string `json:"server"` - Nick string `json:"nick"` - Map string `json:"map"` - Game string `json:"game"` - Duration float64 `json:"duration"` - Ticks int `json:"ticks"` - Frames int `json:"frames"` - 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 DemoState `json:"state"` - Header DemoHeader `json:"header"` -} diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 1f207b8a..ccb201d0 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -166,5 +166,4 @@ var ( ErrOpenFile = errors.New("could not open output file") ErrFrontendRoutes = errors.New("failed to initialize frontend asset routes") ErrPathInvalid = errors.New("invalid path specified") - ErrDemoLoad = errors.New("could not load demo file") ) diff --git a/internal/domain/match.go b/internal/domain/match.go index 5f0cb23d..1ea66c15 100644 --- a/internal/domain/match.go +++ b/internal/domain/match.go @@ -7,6 +7,7 @@ import ( "time" "github.com/gofrs/uuid/v5" + "github.com/leighmacdonald/gbans/pkg/demoparse" "github.com/leighmacdonald/gbans/pkg/fp" "github.com/leighmacdonald/gbans/pkg/logparse" "github.com/leighmacdonald/steamid/v4/steamid" @@ -14,7 +15,7 @@ import ( ) type MatchRepository interface { - Matches(ctx context.Context, opts MatchesQueryOpts) ([]MatchSummary, int64, error) + Matches(ctx context.Context, opts MatchesQueryOpts) ([]MatchResult, 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 StatsPlayerClass(ctx context.Context, sid64 steamid.SteamID) (PlayerClassStatsCollection, error) @@ -38,9 +39,9 @@ type MatchRepository interface { GetMatchIDFromServerID(serverID int) (uuid.UUID, bool) } type MatchUsecase interface { - CreateFromDemo(ctx context.Context, serverID int, details DemoDetails) (MatchSummary, error) + CreateFromDemo(ctx context.Context, serverID int, details demoparse.Demo) (MatchResult, error) GetMatchIDFromServerID(serverID int) (uuid.UUID, bool) - Matches(ctx context.Context, opts MatchesQueryOpts) ([]MatchSummary, int64, error) + Matches(ctx context.Context, opts MatchesQueryOpts) ([]MatchResult, 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 StatsPlayerClass(ctx context.Context, sid64 steamid.SteamID) (PlayerClassStatsCollection, error) @@ -167,6 +168,13 @@ type MatchWeapon struct { MatchPlayerID int64 `json:"match_player_id"` } +type MatchChat struct { + SteamID steamid.SteamID `json:"steam_id"` + PersonaName string `json:"persona_name"` + Body string `json:"body"` + Team bool `json:"team"` +} + type MatchResult struct { MatchID uuid.UUID `json:"match_id"` ServerID int `json:"server_id"` @@ -177,7 +185,11 @@ type MatchResult struct { TimeEnd time.Time `json:"time_end"` Winner logparse.Team `json:"winner"` Players []*MatchPlayer `json:"players"` - Chat PersonMessages `json:"chat"` + Chat []MatchChat `json:"chat"` +} + +func (match *MatchResult) Path() string { + return "/log/" + match.MatchID.String() } func (match *MatchResult) TopPlayers() []*MatchPlayer { @@ -424,7 +436,7 @@ type PlayerMedicStats struct { type CommonPlayerStats struct { SteamID steamid.SteamID `json:"steam_id"` Name string `json:"name"` - AvatarHash string `json:"avatar_hash"` //todo make + AvatarHash string `json:"avatar_hash"` // todo make Kills int `json:"kills"` Assists int `json:"assists"` Deaths int `json:"deaths"` @@ -455,20 +467,3 @@ type PlayerStats struct { MatchesWon int `json:"matches_won"` PlayTime time.Duration `json:"play_time"` } - -type MatchSummary struct { - MatchID uuid.UUID `json:"match_id"` - ServerID int `json:"server_id"` - IsWinner bool `json:"is_winner"` - ShortName string `json:"short_name"` - Title string `json:"title"` - MapName string `json:"map_name"` - ScoreBlu int `json:"score_blu"` - ScoreRed int `json:"score_red"` - TimeStart time.Time `json:"time_start"` - TimeEnd time.Time `json:"time_end"` -} - -func (m MatchSummary) Path() string { - return "/log/" + m.MatchID.String() -} diff --git a/internal/match/match_repository.go b/internal/match/match_repository.go index 7c729941..3952fb77 100644 --- a/internal/match/match_repository.go +++ b/internal/match/match_repository.go @@ -49,7 +49,7 @@ func (r *matchRepository) GetMatchIDFromServerID(serverID int) (uuid.UUID, bool) return r.matchUUIDMap.Get(serverID) } -func (r *matchRepository) Matches(ctx context.Context, opts domain.MatchesQueryOpts) ([]domain.MatchSummary, int64, error) { +func (r *matchRepository) Matches(ctx context.Context, opts domain.MatchesQueryOpts) ([]domain.MatchResult, int64, error) { countBuilder := r.database. Builder(). Select("count(m.match_id) as count"). @@ -103,12 +103,14 @@ func (r *matchRepository) Matches(ctx context.Context, opts domain.MatchesQueryO defer rows.Close() - var matches []domain.MatchSummary + var matches []domain.MatchResult for rows.Next() { - var summary domain.MatchSummary - if errScan := rows.Scan(&summary.MatchID, &summary.ServerID, &summary.IsWinner, &summary.ShortName, - &summary.Title, &summary.MapName, &summary.ScoreBlu, &summary.ScoreRed, &summary.TimeStart, + var summary domain.MatchResult + var winner bool + var shortName string + if errScan := rows.Scan(&summary.MatchID, &summary.ServerID, &winner, &shortName, + &summary.Title, &summary.MapName, &summary.TeamScores.Blu, &summary.TeamScores.Red, &summary.TimeStart, &summary.TimeEnd); errScan != nil { return nil, 0, errors.Join(errScan, domain.ErrScanResult) } @@ -533,15 +535,21 @@ func (r *matchRepository) MatchGetByID(ctx context.Context, matchID uuid.UUID, m } chat, errChat := r.matchGetChat(ctx, matchID) - if errChat != nil && !errors.Is(errChat, domain.ErrNoResult) { return errChat } - match.Chat = chat + for _, msg := range chat { + match.Chat = append(match.Chat, domain.MatchChat{ + SteamID: msg.SteamID, + PersonaName: msg.PersonaName, + Body: msg.Body, + Team: msg.Team, + }) + } if match.Chat == nil { - match.Chat = domain.PersonMessages{} + match.Chat = []domain.MatchChat{} } for _, player := range match.Players { diff --git a/internal/match/match_usecase.go b/internal/match/match_usecase.go index 28db3613..2a31fe83 100644 --- a/internal/match/match_usecase.go +++ b/internal/match/match_usecase.go @@ -4,24 +4,24 @@ import ( "context" "errors" "math" - "strings" "time" "github.com/gofrs/uuid/v5" "github.com/leighmacdonald/gbans/internal/domain" + "github.com/leighmacdonald/gbans/pkg/demoparse" "github.com/leighmacdonald/gbans/pkg/fp" "github.com/leighmacdonald/gbans/pkg/logparse" "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 -} +// 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 @@ -41,39 +41,94 @@ func NewMatchUsecase(repository domain.MatchRepository, state domain.StateUsecas } } -func (m matchUsecase) CreateFromDemo(ctx context.Context, serverID int, details domain.DemoDetails) (domain.MatchSummary, error) { +func (m matchUsecase) CreateFromDemo(_ context.Context, serverID int, details demoparse.Demo) (domain.MatchResult, error) { server, errServer := m.servers.Server(context.Background(), serverID) if errServer != nil { - return domain.MatchSummary{}, errServer + return domain.MatchResult{}, errServer } newID, errID := uuid.NewV4() if errID != nil { - return domain.MatchSummary{}, errors.Join(errID, domain.ErrUUIDCreate) + return domain.MatchResult{}, errors.Join(errID, domain.ErrUUIDCreate) } 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, + result := domain.MatchResult{ + MatchID: newID, + ServerID: server.ServerID, + Title: details.Header.Server, + MapName: details.Header.Map, + TeamScores: logparse.TeamScores{ + Red: details.State.Results.ScoreRed, + RedTime: details.State.Results.RedTime, + Blu: details.State.Results.ScoreBlu, + BluTime: details.State.Results.BluTime, + }, TimeStart: startTime, TimeEnd: endTime, + Winner: logparse.BLU, + Players: nil, + Chat: nil, + } + + for _, chat := range details.State.Chat { + result.Chat = append(result.Chat, domain.MatchChat{ + SteamID: steamid.New(chat.SteamID), + PersonaName: chat.PersonaName, + Body: chat.Body, + Team: chat.Team, + }) + } + + for uid, player := range details.State.Players { + user := details.State.Users[uid] + matchPlayer := domain.MatchPlayer{ + CommonPlayerStats: domain.CommonPlayerStats{ + SteamID: steamid.New(user.SteamID), + Name: user.Name, + Kills: player.Kills, + Assists: player.Assists, + Deaths: player.Deaths, + Suicides: 0, + Dominations: player.Dominations, + Dominated: 0, + Revenges: player.Revenges, + Damage: player.DamageDealt, + DamageTaken: player.DamageTaken, + HealingTaken: player.HealingTaken, + HealthPacks: player.HealthPacks, + HealingPacks: player.HealingPacks, + Captures: player.Captures, + CapturesBlocked: player.Defenses, + Extinguishes: player.Extinguishes, + BuildingBuilt: player.BuildingBuilt, + BuildingDestroyed: player.BuildingDestroyed, + Backstabs: player.Backstabs, + Airshots: player.Airshots, + Headshots: player.Headshots, + Shots: player.Shots, + Hits: player.Hits, + }, + Team: user.Team, + TimeStart: time.Time{}, + TimeEnd: time.Time{}, + MedicStats: nil, + Classes: nil, + Killstreaks: nil, + Weapons: nil, + } + + result.Players = append(result.Players, &matchPlayer) } - return s, nil + return result, nil } func (m matchUsecase) GetMatchIDFromServerID(serverID int) (uuid.UUID, bool) { return m.repository.GetMatchIDFromServerID(serverID) } -func (m matchUsecase) Matches(ctx context.Context, opts domain.MatchesQueryOpts) ([]domain.MatchSummary, int64, error) { +func (m matchUsecase) Matches(ctx context.Context, opts domain.MatchesQueryOpts) ([]domain.MatchResult, int64, error) { return m.repository.Matches(ctx, opts) } diff --git a/internal/test/demos_test.go b/internal/test/demos_test.go index bfa616b0..dbc22ea3 100644 --- a/internal/test/demos_test.go +++ b/internal/test/demos_test.go @@ -10,6 +10,7 @@ import ( "github.com/leighmacdonald/gbans/internal/demo" "github.com/leighmacdonald/gbans/internal/domain" + "github.com/leighmacdonald/gbans/pkg/demoparse" "github.com/leighmacdonald/gbans/pkg/fs" "github.com/stretchr/testify/require" ) @@ -63,7 +64,7 @@ func TestDemoUpload(t *testing.T) { t.Skip("Parser url undefined") } demoPath := fs.FindFile(path.Join("testdata", "test.dem"), "gbans") - detail, err := demoUC.SendAndParseDemo(context.Background(), demoPath) + detail, err := demoparse.Submit(context.Background(), configUC.Config().Demo.DemoParserURL, demoPath) require.NoError(t, err) require.Len(t, detail.State.Users, 46) } diff --git a/internal/test/match_test.go b/internal/test/match_test.go index 584bd20a..46ae6680 100644 --- a/internal/test/match_test.go +++ b/internal/test/match_test.go @@ -1,47 +1,72 @@ -package test +package test_test import ( - "fmt" + "context" + "testing" + "github.com/leighmacdonald/gbans/internal/domain" + "github.com/leighmacdonald/gbans/pkg/demoparse" "github.com/leighmacdonald/gbans/pkg/logparse" "github.com/leighmacdonald/gbans/pkg/stringutil" "github.com/leighmacdonald/steamid/v4/steamid" + "github.com/stretchr/testify/require" "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), +func generateDemoDetails(players int) demoparse.Demo { + demo := demoparse.Demo{ + Header: demoparse.Header{ + DemoType: domain.DemoType, + Version: 3, + Protocol: 24, + Server: "Test server: " + 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)), + }, + State: demoparse.GameState{ + Users: make(map[int]demoparse.Player), + Players: make(map[int]demoparse.PlayerSummary), + Results: demoparse.Results{}, + Rounds: make([]demoparse.DemoRoundSummary, 0), + Chat: make([]demoparse.ChatMessage, 0), + }, } weaponIdx := 1 - for i := range players { + for playerIdx := range players { team := logparse.BLU - if i%2 == 0 { + if playerIdx%2 == 0 { team = logparse.RED } - s.Users[i+1] = domain.DemoPlayer{ + demo.State.Users[playerIdx+1] = demoparse.Player{ Classes: nil, Name: stringutil.SecureRandomString(10), - UserID: i, + UserID: playerIdx, SteamID: steamid.RandSID64(), Team: team, } - w := make(map[logparse.Weapon]domain.DemoWeaponDetail) + weaponSum := make(map[demoparse.WeaponID]demoparse.WeaponSummary) 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)), + weaponSum[demoparse.WeaponID(rune(weaponIdx))] = demoparse.WeaponSummary{ + Kills: int(rand.Int31n(200)), + Damage: int(rand.Int31n(30000)), + Shots: int(rand.Int31n(500)), + Hits: int(rand.Int31n(200)), + Backstabs: int(rand.Int31n(20)), + Headshots: int(rand.Int31n(20)), + Airshots: int(rand.Int31n(20)), } } - s.DemoPlayerSummaries[i+1] = domain.DemoPlayerSummaries{ + demo.State.Players[playerIdx+1] = demoparse.PlayerSummary{ Points: int(rand.Int31n(200)), Kills: int(rand.Int31n(200)), Assists: int(rand.Int31n(200)), @@ -58,27 +83,29 @@ func genMatch(players int) domain.DemoDetails { Backstabs: int(rand.Int31n(50000)), BonusPoints: int(rand.Int31n(2000)), Support: int(rand.Int31n(20000)), - DamgageDealt: int(rand.Int31n(50000)), - WeaponMap: w, + DamageDealt: int(rand.Int31n(50000)), + DamageTaken: int(rand.Int31n(200)), + HealingTaken: int(rand.Int31n(200)), + HealthPacks: int(rand.Int31n(200)), + HealingPacks: int(rand.Int31n(200)), + Extinguishes: int(rand.Int31n(200)), + BuildingBuilt: int(rand.Int31n(200)), + BuildingDestroyed: int(rand.Int31n(200)), + Airshots: int(rand.Int31n(200)), + Shots: int(rand.Int31n(200)), + Hits: int(rand.Int31n(200)), + WeaponMap: weaponSum, } } - 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 demo +} + +func TestMatchFromDemo(t *testing.T) { + demoDetails := generateDemoDetails(24) + + match, errMatch := matchUC.CreateFromDemo(context.Background(), testServer.ServerID, demoDetails) + require.NoError(t, errMatch) - return d + require.Equal(t, match.MapName, demoDetails.Header.Map) } diff --git a/pkg/demoparse/consts.go b/pkg/demoparse/consts.go new file mode 100644 index 00000000..394a0b09 --- /dev/null +++ b/pkg/demoparse/consts.go @@ -0,0 +1,154 @@ +package demoparse + +// Team represents a players team, or spectator state. +type Team int + +const ( + UNASSIGNED Team = iota + SPEC + RED + BLU +) + +type PlayerClass int + +//goland:noinspection GoUnnecessarilyExportedIdentifiers +const ( + Spectator PlayerClass = iota + Scout + Sniper + Soldier + Demoman + Medic + Heavy + Pyro + Spy + Engineer + Multi +) + +type RoundState int + +const ( + Init RoundState = iota + Pregame + StartGame + PreRound + RoundRunning + TeamWin + Restart + Stalemate + GameOver + Bonus + BetweenRounds +) + +type WeaponID int + +const ( + WeaponNone WeaponID = iota + WeaponBat + WeaponBatWood + WeaponBottle + WeaponFireaxe + WeaponClub + WeaponCrowbar + WeaponKnife + WeaponFists + WeaponShovel + WeaponWrench + WeaponBonesaw + WeaponShotgunPrimary + WeaponShotgunSoldier + WeaponShotgunHwg + WeaponShotgunPyro + WeaponScattergun + WeaponSniperrifle + WeaponMinigun + WeaponSmg + WeaponSyringegunMedic + WeaponTranq + WeaponRocketlauncher + WeaponGrenadelauncher + WeaponPipebomblauncher + WeaponFlamethrower + WeaponGrenadeNormal + WeaponGrenadeConcussion + WeaponGrenadeNail + WeaponGrenadeMirv + WeaponGrenadeMirvDemoman + WeaponGrenadeNapalm + WeaponGrenadeGas + WeaponGrenadeEmp + WeaponGrenadeCaltrop + WeaponGrenadePipebomb + WeaponGrenadeSmokeBomb + WeaponGrenadeHeal + WeaponGrenadeStunball + WeaponGrenadeJar + WeaponGrenadeJarMilk + WeaponPistol + WeaponPistolScout + WeaponRevolver + WeaponNailgun + WeaponPda + WeaponPdaEngineerBuild + WeaponPdaEngineerDestroy + WeaponPdaSpy + WeaponBuilder + WeaponMedigun + WeaponGrenadeMirvbomb + WeaponFlamethrowerRocket + WeaponGrenadeDemoman + WeaponSentryBullet + WeaponSentryRocket + WeaponDispenser + WeaponInvis + WeaponFlaregun + WeaponLunchbox + WeaponJar + WeaponCompoundBow + WeaponBuffItem + WeaponPumpkinBomb + WeaponSword + WeaponRocketlauncherDirecthit + WeaponLifeline + WeaponLaserPointer + WeaponDispenserGun + WeaponSentryRevenge + WeaponJarMilk + WeaponHandgunScoutPrimary + WeaponBatFish + WeaponCrossbow + WeaponStickbomb + WeaponHandgunScoutSecondary + WeaponSodaPopper + WeaponSniperrifleDecap + WeaponRaygun + WeaponParticleCannon + WeaponMechanicalArm + WeaponDrgPomson + WeaponBatGiftwrap + WeaponGrenadeOrnamentBall + WeaponFlaregunRevenge + WeaponPepBrawlerBlaster + WeaponCleaver + WeaponGrenadeCleaver + WeaponStickyBallLauncher + WeaponGrenadeStickyBall + WeaponShotgunBuildingRescue + WeaponCannon + WeaponThrowable + WeaponGrenadeThrowable + WeaponPdaSpyBuild + WeaponGrenadeWaterballoon + WeaponHarvesterSaw + WeaponSpellbook + WeaponSpellbookProjectile + WeaponSniperrifleClassic + WeaponParachute + WeaponGrapplinghook + WeaponPasstimeGun + WeaponSniperrifleRevolver + WeaponChargedSmg +) diff --git a/pkg/demoparse/demo.go b/pkg/demoparse/demo.go new file mode 100644 index 00000000..999f73cb --- /dev/null +++ b/pkg/demoparse/demo.go @@ -0,0 +1,98 @@ +package demoparse + +import ( + "github.com/leighmacdonald/gbans/pkg/logparse" + "github.com/leighmacdonald/steamid/v4/steamid" +) + +type Demo struct { + State GameState `json:"state"` + Header Header `json:"header"` +} + +type GameState struct { + Users map[int]Player `json:"users"` + Players map[int]PlayerSummary `json:"players"` //nolint:tagliatelle + Results Results `json:"results"` //nolint:tagliatelle + Rounds []DemoRoundSummary `json:"rounds"` + Chat []ChatMessage `json:"chat"` +} + +type Header struct { + DemoType string `json:"demo_type"` + Version int `json:"version"` + Protocol int `json:"protocol"` + Server string `json:"server"` + Nick string `json:"nick"` + Map string `json:"map"` + Game string `json:"game"` + Duration float64 `json:"duration"` + Ticks int `json:"ticks"` + Frames int `json:"frames"` + Signon int `json:"signon"` +} + +type Player struct { + Classes map[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 WeaponSummary 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 PlayerSummary 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"` + DamageDealt int `json:"damage_dealt"` + DamageTaken int `json:"damage_taken"` + HealingTaken int `json:"healing_taken"` + HealthPacks int `json:"health_packs"` + HealingPacks int `json:"healing_packs"` + Extinguishes int `json:"extinguishes"` + BuildingBuilt int `json:"building_built"` + BuildingDestroyed int `json:"building_destroyed"` + Airshots int `json:"airshots"` + Shots int `json:"shots"` + Hits int `json:"hits"` + WeaponMap map[WeaponID]WeaponSummary `json:"weapon_map"` +} + +type ChatMessage struct { + SteamID string `json:"steam_id"` + PersonaName string `json:"persona_name"` + Body string `json:"body"` + Team bool `json:"team"` +} + +type Results struct { + ScoreBlu int `json:"score_blu"` + BluTime int `json:"blu_time"` + ScoreRed int `json:"score_red"` + RedTime int `json:"red_time"` +} + +type DemoRoundSummary struct{} diff --git a/pkg/demoparse/service.go b/pkg/demoparse/service.go new file mode 100644 index 00000000..7a773662 --- /dev/null +++ b/pkg/demoparse/service.go @@ -0,0 +1,77 @@ +package demoparse + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "mime/multipart" + "net/http" + "os" +) + +var ErrDemoSubmit = errors.New("could not submit demo file") + +func Submit(ctx context.Context, url string, path string) (*Demo, error) { + fileHandle, errDF := os.Open(path) + if errDF != nil { + return nil, errors.Join(errDF, ErrDemoSubmit) + } + + content, errContent := io.ReadAll(fileHandle) + if errContent != nil { + return nil, errors.Join(errDF, ErrDemoSubmit) + } + + info, errInfo := fileHandle.Stat() + if errInfo != nil { + return nil, errors.Join(errInfo, ErrDemoSubmit) + } + + defer fileHandle.Close() + + body := new(bytes.Buffer) + writer := multipart.NewWriter(body) + + part, errCreate := writer.CreateFormFile("file", info.Name()) + if errCreate != nil { + return nil, errors.Join(errCreate, ErrDemoSubmit) + } + + if _, err := part.Write(content); err != nil { + return nil, errors.Join(errCreate, ErrDemoSubmit) + } + + if errClose := writer.Close(); errClose != nil { + return nil, errors.Join(errClose, ErrDemoSubmit) + } + + req, errReq := http.NewRequestWithContext(ctx, http.MethodPost, url, body) + if errReq != nil { + return nil, errors.Join(errReq, ErrDemoSubmit) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + client := &http.Client{} + resp, errSend := client.Do(req) + if errSend != nil { + return nil, errors.Join(errSend, ErrDemoSubmit) + } + + defer resp.Body.Close() + + var demo Demo + + // TODO remove this extra copy once this feature doesnt have much need for debugging/inspection. + rawBody, errRead := io.ReadAll(resp.Body) + if errRead != nil { + return nil, errors.Join(errRead, ErrDemoSubmit) + } + + if errDecode := json.NewDecoder(bytes.NewReader(rawBody)).Decode(&demo); errDecode != nil { + return nil, errors.Join(errDecode, ErrDemoSubmit) + } + + return &demo, nil +}