This commit is contained in:
@@ -19,9 +19,15 @@ namespace LaDOSE.Business.Service
|
||||
/// - 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"/>
|
||||
/// issues one query per table and hands the loaded lists to the pure static
|
||||
/// <see cref="Aggregate"/>, which is unit-testable without a database.
|
||||
/// 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
|
||||
{
|
||||
@@ -78,6 +84,108 @@ namespace LaDOSE.Business.Service
|
||||
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>
|
||||
@@ -224,6 +332,142 @@ namespace LaDOSE.Business.Service
|
||||
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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user