268 lines
10 KiB
C#
268 lines
10 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.
|
|
///
|
|
/// The database work and the aggregation are deliberately separated: <see cref="GetMatchStats"/>
|
|
/// issues one query per table and hands the loaded lists to the pure static
|
|
/// <see cref="Aggregate"/>, which is 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));
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
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 "#<id>" (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}";
|
|
}
|
|
}
|
|
}
|