diff --git a/csgo/demo_loader.go b/csgo/demo_loader.go index 8d7e962..83c654e 100644 --- a/csgo/demo_loader.go +++ b/csgo/demo_loader.go @@ -11,6 +11,7 @@ import ( "github.com/an0nfunc/go-steam/v3/netutil" "github.com/an0nfunc/go-steam/v3/protocol/gamecoordinator" "github.com/an0nfunc/go-steam/v3/protocol/steamlang" + "github.com/go-redis/cache/v8" log "github.com/sirupsen/logrus" "go.uber.org/ratelimit" "google.golang.org/protobuf/proto" @@ -37,6 +38,7 @@ type DemoMatchLoaderConfig struct { Worker int ApiKey string RateLimit ratelimit.Limiter + Cache *cache.Cache } type DemoMatchLoader struct { @@ -53,6 +55,7 @@ type DemoMatchLoader struct { dp *DemoParser parseDemo chan *Demo parseMap map[string]bool + cache *cache.Cache } func AccountId2SteamId(accId uint32) uint64 { @@ -172,6 +175,7 @@ func (d *DemoMatchLoader) Setup(config *DemoMatchLoaderConfig) error { d.db = config.Db d.dp = &DemoParser{} d.parseMap = map[string]bool{} + d.cache = config.Cache err := d.dp.Setup(config.Db, config.Lock, config.Worker) if err != nil { return err @@ -486,6 +490,12 @@ func (d *DemoMatchLoader) gcWorker(apiKey string, rl ratelimit.Limiter) { } } + // clear cache for player + for _, p := range players { + _ = d.cache.Delete(context.Background(), fmt.Sprintf(utils.SideMetaCacheKey, p.ID)) + _ = d.cache.Delete(context.Background(), fmt.Sprintf(utils.MatchMetaCacheKey, p.ID)) + } + err = d.dp.ParseDemo(demo) if err != nil { log.Warningf("[DL] Can't queue demo %d for parsing: %v", demo.MatchId, err) diff --git a/main.go b/main.go index c375dac..6e66659 100644 --- a/main.go +++ b/main.go @@ -46,6 +46,7 @@ var ( configFlag = flag.String("config", "config.yaml", "Set config to use") authCodeFlag = flag.String("authcode", "", "Provide Steam AuthCode to login") journalLogFlag = flag.Bool("journal", false, "Log to systemd journal instead of stdout") + sqlDebugFlag = flag.Bool("sqldebug", false, "Debug SQL queries") ) func housekeeping() { @@ -146,6 +147,64 @@ func housekeeping() { } } +func getPlayerMeta(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", conf.Httpd.CORSAllowDomains) + id := mux.Vars(r)["id"] + l := mux.Vars(r)["limit"] + + var limit int + var err error + if l != "" { + limit, err = strconv.Atoi(l) + if err != nil { + log.Infof("[GPM] limit not an int: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + } else { + limit = 4 + } + + tPlayer, err := utils.GetPlayer(db, id, conf.Steam.APIKey, nil) + if err != nil { + log.Infof("[GP] Player not found: %+v", err) + w.WriteHeader(http.StatusNotFound) + return + } + + metaStats := new(utils.MetaStatsResponse) + err = rdc.Get(context.Background(), fmt.Sprintf(utils.SideMetaCacheKey, tPlayer.ID), &metaStats) + if err != nil { + metaStats, err = utils.GetMetaStats(tPlayer, db.Lock, limit) + if err != nil { + log.Infof("[GPM] Unable to get MetaStats: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + err = rdc.Set(&cache.Item{ + Ctx: context.Background(), + Key: fmt.Sprintf(utils.SideMetaCacheKey, tPlayer.ID), + Value: metaStats, + TTL: time.Hour * 24 * 30, + }) + if err != nil { + log.Errorf("[GPM] Failure saving to cache: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + log.Debugf("[GPM] SideMetaStats for %d saved to cache", tPlayer.ID) + } else { + log.Debugf("[GPM] SideMetaStats for %d from cache", tPlayer.ID) + } + + err = utils.SendJSON(metaStats, w) + if err != nil { + log.Errorf("[GPM] Unable to marshal JSON: %v", err) + w.WriteHeader(http.StatusInternalServerError) + } +} + func getPlayer(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", conf.Httpd.CORSAllowDomains) id := mux.Vars(r)["id"] @@ -205,7 +264,7 @@ func getPlayer(w http.ResponseWriter, r *http.Request) { } metaStats := new(utils.MatchStats) - err = rdc.Get(context.Background(), fmt.Sprintf("csgowtfd_meta_%d", tPlayer.ID), &metaStats) + err = rdc.Get(context.Background(), fmt.Sprintf(utils.MatchMetaCacheKey, tPlayer.ID), &metaStats) if err != nil { wins, ties, losses, err := utils.GetMatchStats(tPlayer, db.Lock) if err != nil { @@ -222,9 +281,9 @@ func getPlayer(w http.ResponseWriter, r *http.Request) { err = rdc.Set(&cache.Item{ Ctx: context.Background(), - Key: fmt.Sprintf("csgowtfd_meta_%d", tPlayer.ID), + Key: fmt.Sprintf(utils.MatchMetaCacheKey, tPlayer.ID), Value: response.MatchStats, - TTL: time.Hour * 12, + TTL: time.Hour * 24 * 30, }) if err != nil { log.Errorf("[GP] Failure saving to cache: %v", err) @@ -233,7 +292,7 @@ func getPlayer(w http.ResponseWriter, r *http.Request) { } log.Debugf("[GP] Metastats for %d saved to cache", tPlayer.ID) } else { - log.Debugf("[GP] Metastats for %d from redis", tPlayer.ID) + log.Debugf("[GP] Metastats for %d from cache", tPlayer.ID) response.MatchStats = &utils.MatchStats{ Win: metaStats.Win, @@ -734,6 +793,10 @@ func main() { }(db.Client) } + if *sqlDebugFlag { + db.Client = db.Client.Debug() + } + if err := db.Client.Schema.Create( context.Background(), migrate.WithDropIndex(true), @@ -779,7 +842,8 @@ func main() { // routes router = mux.NewRouter().StrictSlash(true) router.HandleFunc("/player/{id}", getPlayer).Methods(http.MethodGet, http.MethodOptions) - router.HandleFunc(`/player/{id}/after/{time:\d+}`, getPlayer).Methods(http.MethodGet, http.MethodOptions) + router.HandleFunc(`/player/{id}/next/{time:\d+}`, getPlayer).Methods(http.MethodGet, http.MethodOptions) + router.HandleFunc(`/player/{id}/meta/{limit:\d*}`, getPlayerMeta).Methods(http.MethodGet, http.MethodOptions) router.HandleFunc("/player/{id}/track", postPlayerTrack).Methods(http.MethodPost, http.MethodOptions) router.HandleFunc("/player/{id}/track", deletePlayerTrack).Methods(http.MethodOptions, http.MethodDelete) router.HandleFunc("/match/parse/{sharecode}", getMatchParse).Methods(http.MethodGet, http.MethodOptions) diff --git a/utils/utils.go b/utils/utils.go index dcd5b5e..83e0bbc 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "github.com/an0nfunc/go-steamapi" + "github.com/markus-wa/demoinfocs-golang/v2/pkg/demoinfocs/common" log "github.com/sirupsen/logrus" "go.uber.org/ratelimit" "io" @@ -18,6 +19,7 @@ import ( "net/http" "path" "regexp" + "sort" "strconv" "strings" "sync" @@ -137,16 +139,33 @@ type PlayerResponse struct { VAC bool `json:"vac"` VACDate int64 `json:"vac_date,omitempty"` GameBan bool `json:"game_ban"` - GameBanDate int64 `json:"game_ban_date"` + GameBanDate int64 `json:"game_ban_date,omitempty"` Tracked bool `json:"tracked"` VanityURL string `json:"vanity_url,omitempty"` MatchStats *MatchStats `json:"match_stats,omitempty"` Matches []*MatchResponse `json:"matches,omitempty"` } -type WeaponResponse struct { - Player *PlayerResponse `json:"player"` - Eq map[string][][]int `json:"eq,omitempty"` +type MateResponse struct { + Player *PlayerResponse `json:"player"` + WinRate float32 `json:"win_rate,omitempty"` + TieRate float32 `json:"tie_rate,omitempty"` + Total int `json:"total,omitempty"` +} + +type WeaponDmg struct { + Eq int `json:"eq"` + Dmg uint `json:"dmg"` +} + +type MetaStatsResponse struct { + Player *PlayerResponse `json:"player"` + BestMates []*MateResponse `json:"best_mates,omitempty"` + MostMates []*MateResponse `json:"most_mates,omitempty"` + EqMap map[int]string `json:"eq_map,omitempty"` + WeaponDmg []*WeaponDmg `json:"weapon_dmg,omitempty"` + WinMaps map[string]float32 `json:"win_maps,omitempty"` + TieMaps map[string]float32 `json:"tie_maps,omitempty"` } type MatchResponse struct { @@ -177,6 +196,8 @@ type ( const ( shareCodeURLEntry = "https://api.steampowered.com/ICSGOPlayers_730/GetNextMatchSharingCode/v1?key=%s&steamid=%d&steamidkey=%s&knowncode=%s" + SideMetaCacheKey = "csgowtfd_side_meta_%d" + MatchMetaCacheKey = "csgowtfd_match_meta_%d" ) //goland:noinspection SpellCheckingInspection @@ -201,6 +222,180 @@ func SendJSON(data interface{}, w http.ResponseWriter) error { } func GetMatchStats(dbPlayer *ent.Player, lock *sync.RWMutex) (int, int, int, error) { + wins, loss, ties, err := getWinLossTieFromPlayer(dbPlayer, lock) + if err != nil { + return 0, 0, 0, err + } + + return wins, ties, loss, nil +} + +func GetMetaStats(dbPlayer *ent.Player, lock *sync.RWMutex, limit int) (*MetaStatsResponse, error) { + mResponse := new(MetaStatsResponse) + mResponse.Player = &PlayerResponse{SteamID64: dbPlayer.ID} + + lock.RLock() + tPlayers, err := dbPlayer.QueryMatches().QueryPlayers().All(context.Background()) + lock.RUnlock() + if err != nil { + return nil, err + } + + lock.RLock() + matchIDs, err := dbPlayer.QueryMatches().IDs(context.Background()) + lock.RUnlock() + if err != nil { + return nil, err + } + + mapWins := map[string]int{} + mapTies := map[string]int{} + mapMatchTotal := map[string]int{} + matchSeen := map[uint64]bool{} + mResponse.EqMap = map[int]string{} + + for _, s := range tPlayers { + if s.ID == dbPlayer.ID { + continue + } + + mateRes := new(MateResponse) + mostRes := new(MateResponse) + + playerRes := &PlayerResponse{ + SteamID64: s.ID, + Name: s.Name, + Avatar: s.Avatar, + Tracked: s.AuthCode != "", + VanityURL: s.VanityURLReal, + } + + lock.RLock() + pMatches, err := s.QueryMatches().Where(match.IDIn(matchIDs...)).WithStats().Where(match.HasStatsWith(stats.Or(stats.PlayerStats(dbPlayer.ID), stats.PlayerStats(s.ID)))).All(context.Background()) + lock.RUnlock() + if err != nil { + return nil, err + } + + mostRes.Player = playerRes + var wins int + var ties int + + for _, pm := range pMatches { + var subjectStats *ent.Stats + var currentStats *ent.Stats + + for _, ps := range pm.Edges.Stats { + if ps.PlayerStats == dbPlayer.ID { + subjectStats = ps + } else if ps.PlayerStats == s.ID { + currentStats = ps + } + } + + win := subjectStats.TeamID == pm.MatchResult + tie := pm.MatchResult == 0 + + if _, ok := matchSeen[pm.ID]; !ok { + mapMatchTotal[pm.Map]++ + if win { + mapWins[pm.Map]++ + } else if tie { + mapTies[pm.Map]++ + } + + lock.RLock() + wSs, err := subjectStats.QueryWeaponStats().All(context.Background()) + lock.RUnlock() + if err != nil { + return nil, err + } + + for _, weaponStat := range wSs { + found := false + for _, dmgS := range mResponse.WeaponDmg { + if dmgS.Eq == weaponStat.EqType { + dmgS.Dmg += weaponStat.Dmg + found = true + } + } + + if !found { + mResponse.WeaponDmg = append(mResponse.WeaponDmg, &WeaponDmg{ + Eq: weaponStat.EqType, + Dmg: weaponStat.Dmg, + }) + } + + if _, exist := mResponse.EqMap[weaponStat.EqType]; !exist { + mResponse.EqMap[weaponStat.EqType] = common.EquipmentType(weaponStat.EqType).String() + } + } + + matchSeen[pm.ID] = true + } + + // check if same team + if subjectStats.TeamID == currentStats.TeamID { + mostRes.Total++ + if win { + wins++ + } else if tie { + ties++ + } + } + } + + if mostRes.Total > 0 { + mResponse.MostMates = append(mResponse.MostMates, mostRes) + } + + if mostRes.Total > 0 && (wins > 0 || ties > 0) { + mateRes.Player = playerRes + mateRes.TieRate = float32(ties) / float32(mostRes.Total) + mateRes.WinRate = float32(wins) / float32(mostRes.Total) + mResponse.BestMates = append(mResponse.BestMates, mateRes) + } + } + + mResponse.TieMaps = map[string]float32{} + mResponse.WinMaps = map[string]float32{} + + for tMap, wins := range mapWins { + mResponse.WinMaps[tMap] = float32(wins) / float32(mapMatchTotal[tMap]) + } + + for tMap, ties := range mapTies { + mResponse.TieMaps[tMap] = float32(ties) / float32(mapMatchTotal[tMap]) + } + + // sort all results + sort.Slice(mResponse.BestMates, func(i, j int) bool { + return mResponse.BestMates[i].WinRate > mResponse.BestMates[j].WinRate + }) + + sort.Slice(mResponse.MostMates, func(i, j int) bool { + return mResponse.MostMates[i].Total > mResponse.MostMates[j].Total + }) + + sort.Slice(mResponse.WeaponDmg, func(i, j int) bool { + return mResponse.WeaponDmg[i].Dmg > mResponse.WeaponDmg[j].Dmg + }) + + if len(mResponse.BestMates) > limit { + mResponse.BestMates = mResponse.BestMates[:limit] + } + if len(mResponse.MostMates) > limit { + mResponse.MostMates = mResponse.MostMates[:limit] + } + if len(mResponse.WeaponDmg) > limit { + mResponse.WeaponDmg = mResponse.WeaponDmg[:limit] + } + + return mResponse, nil +} + +func getWinLossTieFromPlayer(dbPlayer *ent.Player, lock *sync.RWMutex) (int, int, int, error) { var res []struct { MatchResult int `json:"match_result"` Count int `json:"count"` @@ -209,8 +404,15 @@ func GetMatchStats(dbPlayer *ent.Player, lock *sync.RWMutex) (int, int, int, err lock.RLock() err := dbPlayer.QueryMatches().GroupBy(match.FieldMatchResult).Aggregate(func(s *sql.Selector) string { sT := sql.Table(stats.Table) + s.Join(sT).On(s.C(match.FieldID), sT.C(stats.MatchesColumn)) - s.Where(sql.And(sql.Or(sql.ColumnsEQ(match.FieldMatchResult, stats.FieldTeamID), sql.EQ(s.C(match.FieldMatchResult), 0)), sql.EQ(sT.C(stats.PlayersColumn), dbPlayer.ID))) + s.Where(sql.And( + sql.Or( + sql.ColumnsEQ(match.FieldMatchResult, stats.FieldTeamID), + sql.EQ(s.C(match.FieldMatchResult), 0), + ), + sql.EQ(sT.C(stats.PlayersColumn), dbPlayer.ID), + )) return sql.Count("*") }).Scan(context.Background(), &res) lock.RUnlock() @@ -227,9 +429,6 @@ func GetMatchStats(dbPlayer *ent.Player, lock *sync.RWMutex) (int, int, int, err return 0, 0, 0, err } - if len(res) < 1 { - return 0, 0, 0, nil - } var ( wins int ties int @@ -244,7 +443,7 @@ func GetMatchStats(dbPlayer *ent.Player, lock *sync.RWMutex) (int, int, int, err } } - return wins, ties, total - wins - ties, nil + return wins, total - wins - ties, ties, nil } func IsAuthCodeValid(player *ent.Player, lock *sync.RWMutex, apiKey string, shareCode string, authCode string, rl ratelimit.Limiter) (bool, error) {