Files
LaDOSE/LaDOSE.Src/LaDOSE.Service/Service/StatisticsService.cs
T
darkstack 937b8554dd
Build App / Build (push) Failing after 3s
Docker Compose bot + Google Api
2026-08-06 16:23:50 +02:00

512 lines
20 KiB
C#

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
{
/// <summary>
/// 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 <see cref="Tournament"/> 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. <see cref="GetVersus"/> drops
/// them and reports how many it dropped.
///
/// The database work and the aggregation are deliberately separated: <see cref="GetMatchStats"/>
/// and <see cref="GetVersus"/> issue one query per table and hand the loaded lists to the pure
/// static <see cref="Aggregate"/> / <see cref="AggregateVersus"/>, which are unit-testable
/// without a database.
/// </summary>
public class StatisticsService : IStatisticsService
{
protected LaDOSEDbContext _context;
public StatisticsService(LaDOSEDbContext context)
{
_context = context;
}
public Task<MatchStats> GetMatchStats(List<int> eventIds)
{
var requested = (eventIds ?? new List<int>()).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<Tournament>()
: _context.Tournament
.Where(t => existingEventIds.Contains(t.EventId))
.ToList();
var tournamentIds = tournaments.Select(t => t.Id).Distinct().ToList();
// _context.Set is the DbSet<Set> property, not DbContext.Set<T>().
var sets = tournamentIds.Count == 0
? new List<Set>()
: _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<Player>()
: _context.Player
.Where(p => playerIds.Contains(p.Id))
.ToList();
return Task.FromResult(Aggregate(requested, events, tournaments, sets, players));
}
public Task<List<PlayerOption>> 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<int, int>();
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<PlayerOption>());
}
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<PlayerVersus> 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<Tournament>()
: _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<Game>()
: _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));
}
/// <summary>
/// Pure aggregation over already-loaded rows. No database, no I/O, deterministic.
/// </summary>
/// <param name="requestedEventIds">The event ids the caller asked for.</param>
/// <param name="events">Events that exist (used for the coverage count).</param>
/// <param name="tournaments">Candidate tournaments; filtered here on EventId.</param>
/// <param name="sets">Candidate sets; filtered here on TournamentId.</param>
/// <param name="players">Players used to resolve display names; may be incomplete.</param>
public static MatchStats Aggregate(
IEnumerable<int> requestedEventIds,
IEnumerable<Event> events,
IEnumerable<Tournament> tournaments,
IEnumerable<Set> sets,
IEnumerable<Player> players)
{
var result = new MatchStats();
var requested = new HashSet<int>((requestedEventIds ?? Enumerable.Empty<int>()));
if (requested.Count == 0)
{
return result;
}
// Coverage: events -------------------------------------------------------
var existingEventIds = new HashSet<int>((events ?? Enumerable.Empty<Event>())
.Where(e => e != null && requested.Contains(e.Id))
.Select(e => e.Id));
result.Coverage.Events = existingEventIds.Count;
// Coverage: brackets ----------------------------------------------------
var scopedTournamentIds = new HashSet<int>((tournaments ?? Enumerable.Empty<Tournament>())
.Where(t => t != null && existingEventIds.Contains(t.EventId))
.Select(t => t.Id));
result.Coverage.Brackets = scopedTournamentIds.Count;
var scopedSets = (sets ?? Enumerable.Empty<Set>())
.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<int, string>();
foreach (var player in (players ?? Enumerable.Empty<Player>()).Where(p => p != null))
{
nameById[player.Id] = DisplayName(player);
}
var stats = new Dictionary<int, PlayerMatchStats>();
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;
}
/// <summary>
/// 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 <see cref="Aggregate"/>: equal
/// scores mean undecided. Undecided meetings still count in Sets — they happened —
/// but contribute to no win and no game count.
/// </summary>
/// <param name="playerAId">Left-hand player; WinsA is always their side.</param>
/// <param name="playerBId">Right-hand player.</param>
/// <param name="sets">Candidate sets; the pairing is re-checked here.</param>
/// <param name="tournaments">Tournaments of those sets, for Tournament.GameId.</param>
/// <param name="games">Games used to resolve names; may be incomplete.</param>
/// <param name="players">Players used to resolve display names; may be incomplete.</param>
public static PlayerVersus AggregateVersus(
int playerAId,
int playerBId,
IEnumerable<Set> sets,
IEnumerable<Tournament> tournaments,
IEnumerable<Game> games,
IEnumerable<Player> players)
{
var result = new PlayerVersus
{
PlayerAId = playerAId,
PlayerBId = playerBId
};
if (playerAId == 0 || playerBId == 0 || playerAId == playerBId)
{
return result;
}
var nameById = new Dictionary<int, string>();
foreach (var player in (players ?? Enumerable.Empty<Player>()).Where(p => p != null))
{
nameById[player.Id] = DisplayName(player);
}
result.PlayerA = ResolveName(playerAId, nameById);
result.PlayerB = ResolveName(playerBId, nameById);
var gameIdByTournament = new Dictionary<int, int?>();
foreach (var tournament in (tournaments ?? Enumerable.Empty<Tournament>()).Where(t => t != null))
{
gameIdByTournament[tournament.Id] = tournament.GameId;
}
var gameById = new Dictionary<int, Game>();
foreach (var game in (games ?? Enumerable.Empty<Game>()).Where(g => g != null))
{
gameById[game.Id] = game;
}
var perGame = new Dictionary<int, VersusGameStats>();
foreach (var set in (sets ?? Enumerable.Empty<Set>()).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<int, VersusGameStats> perGame, int gameId,
Dictionary<int, Game> 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<int, PlayerMatchStats> stats, int playerId,
Dictionary<int, string> nameById)
{
if (!stats.TryGetValue(playerId, out var stat))
{
stat = new PlayerMatchStats
{
PlayerId = playerId,
Player = ResolveName(playerId, nameById)
};
stats[playerId] = stat;
}
return stat;
}
/// <summary>
/// Gamertag, falling back to Name, else "#&lt;id&gt;" (a set can reference a player row
/// that no longer exists, and Gamertag is nullable in the database).
/// </summary>
private static string ResolveName(int playerId, Dictionary<int, string> 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}";
}
}
}