using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using LaDOSE.Business.Interface; using LaDOSE.Entity; using LaDOSE.Entity.Context; namespace LaDOSE.Business.Service { /// /// Match statistics over the persisted Set rows. /// /// Two things to know about the data: /// - There is no Set -> Player relationship in the EF model (the navigation properties /// and their configuration are commented out), so player names are resolved with a /// separate query plus an in-memory dictionary. Nothing here Includes a player. /// - There is no winner column. The winner is inferred from the scores, and start.gg /// encodes a DQ as -1, so games are clamped at 0. /// /// - A Set has no game either. The game belongs to the the set /// was played in, and Tournament.GameId is nullable, so any per-game breakdown has /// to decide what to do with brackets that have none. drops /// them and reports how many it dropped. /// /// The database work and the aggregation are deliberately separated: /// and issue one query per table and hand the loaded lists to the pure /// static / , which are unit-testable /// without a database. /// public class StatisticsService : IStatisticsService { protected LaDOSEDbContext _context; public StatisticsService(LaDOSEDbContext context) { _context = context; } public Task GetMatchStats(List eventIds) { var requested = (eventIds ?? new List()).Distinct().ToList(); if (requested.Count == 0) { // Well-formed and empty, never null and never a 500. return Task.FromResult(new MatchStats()); } // One query per table. No query per set and no query per player. var events = _context.Event .Where(e => requested.Contains(e.Id)) .ToList(); var existingEventIds = events.Select(e => e.Id).ToList(); var tournaments = existingEventIds.Count == 0 ? new List() : _context.Tournament .Where(t => existingEventIds.Contains(t.EventId)) .ToList(); var tournamentIds = tournaments.Select(t => t.Id).Distinct().ToList(); // _context.Set is the DbSet property, not DbContext.Set(). var sets = tournamentIds.Count == 0 ? new List() : _context.Set .Where(s => tournamentIds.Contains(s.TournamentId)) .ToList(); var playerIds = sets.Select(s => s.Player1Id) .Concat(sets.Select(s => s.Player2Id)) .Where(id => id != 0) .Distinct() .ToList(); var players = playerIds.Count == 0 ? new List() : _context.Player .Where(p => playerIds.Contains(p.Id)) .ToList(); return Task.FromResult(Aggregate(requested, events, tournaments, sets, players)); } public Task> GetVersusPlayers() { // Same filter GetVersus applies, so the picker cannot offer a player whose // every meeting would then be dropped as "game unknown". Self-sets are // excluded here too, otherwise they would be counted in both slots. var usable = from s in _context.Set join t in _context.Tournament on s.TournamentId equals t.Id where t.GameId != null && s.Player1Id != 0 && s.Player2Id != 0 && s.Player1Id != s.Player2Id select s; // Counted in the database, one group-by per slot: the set table is the // largest one here and there is no reason to pull it into memory. var asPlayer1 = usable .GroupBy(s => s.Player1Id) .Select(g => new { PlayerId = g.Key, Sets = g.Count() }) .ToList(); var asPlayer2 = usable .GroupBy(s => s.Player2Id) .Select(g => new { PlayerId = g.Key, Sets = g.Count() }) .ToList(); var setsByPlayer = new Dictionary(); foreach (var row in asPlayer1.Concat(asPlayer2)) { setsByPlayer.TryGetValue(row.PlayerId, out var running); setsByPlayer[row.PlayerId] = running + row.Sets; } if (setsByPlayer.Count == 0) { return Task.FromResult(new List()); } var playerIds = setsByPlayer.Keys.ToList(); var nameById = _context.Player .Where(p => playerIds.Contains(p.Id)) .ToList() .ToDictionary(p => p.Id, DisplayName); // A set can reference a player row that no longer exists; keep it as "#id" // rather than hiding a real opponent from the picker. var options = setsByPlayer .Select(pair => new PlayerOption { Id = pair.Key, Name = ResolveName(pair.Key, nameById), Sets = pair.Value }) .OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase) .ThenBy(p => p.Id) .ToList(); return Task.FromResult(options); } public Task GetVersus(int playerAId, int playerBId) { // Nothing to look up, and nothing exceptional either: a caller that has not // picked two distinct players gets an empty breakdown, not a 500. if (playerAId == 0 || playerBId == 0 || playerAId == playerBId) { return Task.FromResult(new PlayerVersus { PlayerAId = playerAId, PlayerBId = playerBId }); } // Either seating: the set rows record whoever start.gg listed first. var sets = _context.Set .Where(s => (s.Player1Id == playerAId && s.Player2Id == playerBId) || (s.Player1Id == playerBId && s.Player2Id == playerAId)) .ToList(); var tournamentIds = sets.Select(s => s.TournamentId).Distinct().ToList(); var tournaments = tournamentIds.Count == 0 ? new List() : _context.Tournament .Where(t => tournamentIds.Contains(t.Id)) .ToList(); var gameIds = tournaments .Where(t => t.GameId.HasValue) .Select(t => t.GameId.Value) .Distinct() .ToList(); var games = gameIds.Count == 0 ? new List() : _context.Game .Where(g => gameIds.Contains(g.Id)) .ToList(); var players = _context.Player .Where(p => p.Id == playerAId || p.Id == playerBId) .ToList(); return Task.FromResult(AggregateVersus(playerAId, playerBId, sets, tournaments, games, players)); } /// /// Pure aggregation over already-loaded rows. No database, no I/O, deterministic. /// /// The event ids the caller asked for. /// Events that exist (used for the coverage count). /// Candidate tournaments; filtered here on EventId. /// Candidate sets; filtered here on TournamentId. /// Players used to resolve display names; may be incomplete. public static MatchStats Aggregate( IEnumerable requestedEventIds, IEnumerable events, IEnumerable tournaments, IEnumerable sets, IEnumerable players) { var result = new MatchStats(); var requested = new HashSet((requestedEventIds ?? Enumerable.Empty())); if (requested.Count == 0) { return result; } // Coverage: events ------------------------------------------------------- var existingEventIds = new HashSet((events ?? Enumerable.Empty()) .Where(e => e != null && requested.Contains(e.Id)) .Select(e => e.Id)); result.Coverage.Events = existingEventIds.Count; // Coverage: brackets ---------------------------------------------------- var scopedTournamentIds = new HashSet((tournaments ?? Enumerable.Empty()) .Where(t => t != null && existingEventIds.Contains(t.EventId)) .Select(t => t.Id)); result.Coverage.Brackets = scopedTournamentIds.Count; var scopedSets = (sets ?? Enumerable.Empty()) .Where(s => s != null && scopedTournamentIds.Contains(s.TournamentId)) .ToList(); result.Coverage.Sets = scopedSets.Count; result.Coverage.BracketsWithSets = scopedSets.Select(s => s.TournamentId).Distinct().Count(); // Name resolution ------------------------------------------------------- var nameById = new Dictionary(); foreach (var player in (players ?? Enumerable.Empty()).Where(p => p != null)) { nameById[player.Id] = DisplayName(player); } var stats = new Dictionary(); var h2h = new Dictionary<(int, int), HeadToHead>(); foreach (var set in scopedSets) { // Unusable rows still count in Coverage.Sets but nowhere else. if (set.Player1Id == 0 || set.Player2Id == 0 || set.Player1Id == set.Player2Id) { continue; } // No winner column: equal scores (including 0-0 and -1 / -1) are undecided. if (set.Player1Score == set.Player2Score) { continue; } result.Coverage.DecidedSets++; var p1 = set.Player1Id; var p2 = set.Player2Id; // A DQ is stored as -1. Clamp so it never produces negative games. var games1 = Math.Max(0, set.Player1Score); var games2 = Math.Max(0, set.Player2Score); var stat1 = GetOrAdd(stats, p1, nameById); var stat2 = GetOrAdd(stats, p2, nameById); stat1.GamesWon += games1; stat1.GamesLost += games2; stat2.GamesWon += games2; stat2.GamesLost += games1; var winnerId = set.Player1Score > set.Player2Score ? p1 : p2; if (winnerId == p1) { stat1.Wins++; stat2.Losses++; } else { stat2.Wins++; stat1.Losses++; } // Lower PlayerId is always A, so WinsA / WinsB are unambiguous. var aId = Math.Min(p1, p2); var bId = Math.Max(p1, p2); var key = (aId, bId); if (!h2h.TryGetValue(key, out var pair)) { pair = new HeadToHead { PlayerAId = aId, PlayerA = ResolveName(aId, nameById), PlayerBId = bId, PlayerB = ResolveName(bId, nameById) }; h2h[key] = pair; } if (winnerId == aId) { pair.WinsA++; } else { pair.WinsB++; } } // Sets is derived, so Sets == Wins + Losses cannot drift. foreach (var stat in stats.Values) { stat.Sets = stat.Wins + stat.Losses; Debug.Assert(stat.Sets == stat.Wins + stat.Losses, "Sets must equal Wins + Losses"); } result.Players = stats.Values .OrderByDescending(p => p.Wins) .ThenByDescending(p => p.Sets) .ThenBy(p => p.Player, StringComparer.Ordinal) .ThenBy(p => p.PlayerId) .ToList(); // Not capped: bounded by the number of sets in scope. result.HeadToHead = h2h.Values .OrderByDescending(p => p.WinsA + p.WinsB) .ThenBy(p => p.PlayerA, StringComparer.Ordinal) .ThenBy(p => p.PlayerB, StringComparer.Ordinal) .ThenBy(p => p.PlayerAId) .ThenBy(p => p.PlayerBId) .ToList(); return result; } /// /// Pure aggregation of the meetings between two players, per game. No database, /// no I/O, deterministic. /// /// Winners are inferred from the scores exactly as in : equal /// scores mean undecided. Undecided meetings still count in Sets — they happened — /// but contribute to no win and no game count. /// /// Left-hand player; WinsA is always their side. /// Right-hand player. /// Candidate sets; the pairing is re-checked here. /// Tournaments of those sets, for Tournament.GameId. /// Games used to resolve names; may be incomplete. /// Players used to resolve display names; may be incomplete. public static PlayerVersus AggregateVersus( int playerAId, int playerBId, IEnumerable sets, IEnumerable tournaments, IEnumerable games, IEnumerable players) { var result = new PlayerVersus { PlayerAId = playerAId, PlayerBId = playerBId }; if (playerAId == 0 || playerBId == 0 || playerAId == playerBId) { return result; } var nameById = new Dictionary(); foreach (var player in (players ?? Enumerable.Empty()).Where(p => p != null)) { nameById[player.Id] = DisplayName(player); } result.PlayerA = ResolveName(playerAId, nameById); result.PlayerB = ResolveName(playerBId, nameById); var gameIdByTournament = new Dictionary(); foreach (var tournament in (tournaments ?? Enumerable.Empty()).Where(t => t != null)) { gameIdByTournament[tournament.Id] = tournament.GameId; } var gameById = new Dictionary(); foreach (var game in (games ?? Enumerable.Empty()).Where(g => g != null)) { gameById[game.Id] = game; } var perGame = new Dictionary(); foreach (var set in (sets ?? Enumerable.Empty()).Where(s => s != null)) { // The query already restricts the pairing; checking again keeps this // method correct on its own, which is the point of it being pure. var aIsPlayer1 = set.Player1Id == playerAId && set.Player2Id == playerBId; var bIsPlayer1 = set.Player1Id == playerBId && set.Player2Id == playerAId; if (!aIsPlayer1 && !bIsPlayer1) { continue; } // No game on the bracket, nothing to file this meeting under. Counted so // the caller can say "3 meetings we cannot attribute" instead of losing them. if (!gameIdByTournament.TryGetValue(set.TournamentId, out var gameId) || !gameId.HasValue) { result.UnknownGameSets++; continue; } var row = GetOrAddGame(perGame, gameId.Value, gameById); row.Sets++; var scoreA = aIsPlayer1 ? set.Player1Score : set.Player2Score; var scoreB = aIsPlayer1 ? set.Player2Score : set.Player1Score; // No winner column: equal scores (including 0-0 and -1 / -1) are undecided. if (scoreA == scoreB) { continue; } row.DecidedSets++; // A DQ is stored as -1. Clamp so it never produces negative games. row.GamesWonA += Math.Max(0, scoreA); row.GamesWonB += Math.Max(0, scoreB); if (scoreA > scoreB) { row.WinsA++; } else { row.WinsB++; } } result.Games = perGame.Values .OrderByDescending(g => g.Sets) .ThenByDescending(g => g.DecidedSets) .ThenBy(g => g.Game, StringComparer.Ordinal) .ThenBy(g => g.GameId) .ToList(); // Totals are derived from the rows, so the header cannot disagree with the table. result.Sets = result.Games.Sum(g => g.Sets); result.DecidedSets = result.Games.Sum(g => g.DecidedSets); result.WinsA = result.Games.Sum(g => g.WinsA); result.WinsB = result.Games.Sum(g => g.WinsB); return result; } private static VersusGameStats GetOrAddGame(Dictionary perGame, int gameId, Dictionary gameById) { if (!perGame.TryGetValue(gameId, out var row)) { gameById.TryGetValue(gameId, out var game); var name = string.IsNullOrWhiteSpace(game?.Name) ? $"#{gameId}" : game.Name; row = new VersusGameStats { GameId = gameId, Game = name, GameLongName = string.IsNullOrWhiteSpace(game?.LongName) ? name : game.LongName }; perGame[gameId] = row; } return row; } private static PlayerMatchStats GetOrAdd(Dictionary stats, int playerId, Dictionary nameById) { if (!stats.TryGetValue(playerId, out var stat)) { stat = new PlayerMatchStats { PlayerId = playerId, Player = ResolveName(playerId, nameById) }; stats[playerId] = stat; } return stat; } /// /// Gamertag, falling back to Name, else "#<id>" (a set can reference a player row /// that no longer exists, and Gamertag is nullable in the database). /// private static string ResolveName(int playerId, Dictionary nameById) { return nameById.TryGetValue(playerId, out var name) ? name : $"#{playerId}"; } private static string DisplayName(Player player) { if (!string.IsNullOrWhiteSpace(player.Gamertag)) { return player.Gamertag; } if (!string.IsNullOrWhiteSpace(player.Name)) { return player.Name; } return $"#{player.Id}"; } } }