Sube carpetas API, API.Tests, Application y Domain del Backend
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Application.Utils.Helper.Champions;
|
||||
|
||||
/// <summary>
|
||||
/// Pure logic that resolves a division's podium from its elimination bracket, with no data access.
|
||||
/// </summary>
|
||||
public static class ChampionResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// A team occupying a podium place.
|
||||
/// </summary>
|
||||
public sealed record TeamRef(Guid TeamId, string TeamName, string LogoUrl);
|
||||
|
||||
/// <summary>
|
||||
/// The three podium places; any place may be null when undecided.
|
||||
/// </summary>
|
||||
public sealed class Podium
|
||||
{
|
||||
public TeamRef? First { get; init; }
|
||||
public TeamRef? Second { get; init; }
|
||||
public TeamRef? Third { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True when Third is null only because the top cup never had a real bracket round to draw a third place from.
|
||||
/// </summary>
|
||||
public bool ImplicitThirdFromStandings { get; init; }
|
||||
}
|
||||
|
||||
private static readonly HashSet<StageType> BracketDepthStageTypes =
|
||||
[
|
||||
StageType.RoundOf16,
|
||||
StageType.QuarterFinal,
|
||||
StageType.SemiFinal,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// The champion of a single sub-cup, a playoff bracket, of a division.
|
||||
/// </summary>
|
||||
public sealed record CupChampion(string? CupName, int SeedOrder, TeamRef Champion);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the podium of a division that has a playoff.
|
||||
/// </summary>
|
||||
/// <param name="eliminationStages">Every non-group stage of the division.</param>
|
||||
/// <param name="mappings">The division's position-range to cup mappings, which may be empty.</param>
|
||||
/// <param name="eliminationMatches">The finished/seeded matches of the elimination stages, with their team navigations loaded.</param>
|
||||
/// <param name="series">The division's best-of-N series, with their team navigations loaded.</param>
|
||||
public static Podium ResolvePlayoffPodium(
|
||||
IReadOnlyList<Stage> eliminationStages,
|
||||
IReadOnlyList<DivisionPlayoffMapping> mappings,
|
||||
IReadOnlyList<Match> eliminationMatches,
|
||||
IReadOnlyList<MatchSeries> series)
|
||||
{
|
||||
string? topBracket = ResolveTopBracketName(eliminationStages, mappings);
|
||||
|
||||
Stage? finalStage = eliminationStages
|
||||
.Where(stage => stage.BracketName == topBracket && stage.StageType == StageType.Final)
|
||||
.OrderBy(stage => stage.Order)
|
||||
.FirstOrDefault();
|
||||
|
||||
Stage? thirdPlaceStage = eliminationStages
|
||||
.Where(stage => stage.BracketName == topBracket && stage.StageType == StageType.ThirdPlace)
|
||||
.OrderBy(stage => stage.Order)
|
||||
.FirstOrDefault();
|
||||
|
||||
Dictionary<Guid, TeamRef> teamsById = BuildTeamLookup(eliminationMatches, series);
|
||||
|
||||
(Guid Winner, Guid Loser)? finalOutcome = finalStage is null
|
||||
? null
|
||||
: ResolveStageOutcome(finalStage, eliminationMatches, series);
|
||||
|
||||
(Guid Winner, Guid Loser)? thirdOutcome = thirdPlaceStage is null
|
||||
? null
|
||||
: ResolveStageOutcome(thirdPlaceStage, eliminationMatches, series);
|
||||
|
||||
TeamRef? third = thirdOutcome is null ? null : Lookup(teamsById, thirdOutcome.Value.Winner);
|
||||
|
||||
bool hasBracketDepth = eliminationStages
|
||||
.Any(stage => stage.BracketName == topBracket && BracketDepthStageTypes.Contains(stage.StageType));
|
||||
|
||||
return new Podium
|
||||
{
|
||||
First = finalOutcome is null ? null : Lookup(teamsById, finalOutcome.Value.Winner),
|
||||
Second = finalOutcome is null ? null : Lookup(teamsById, finalOutcome.Value.Loser),
|
||||
Third = third,
|
||||
ImplicitThirdFromStandings = third is null && !hasBracketDepth,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the champion of every sub-cup, a playoff bracket, of a division, not just the top one.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<CupChampion> ResolveCupChampions(
|
||||
IReadOnlyList<Stage> eliminationStages,
|
||||
IReadOnlyList<DivisionPlayoffMapping> mappings,
|
||||
IReadOnlyList<Match> eliminationMatches,
|
||||
IReadOnlyList<MatchSeries> series)
|
||||
{
|
||||
List<string?> bracketNames = [.. eliminationStages
|
||||
.Select(stage => stage.BracketName)
|
||||
.Distinct()];
|
||||
|
||||
bool singleBracket = bracketNames.Count <= 1;
|
||||
Dictionary<Guid, TeamRef> teamsById = BuildTeamLookup(eliminationMatches, series);
|
||||
|
||||
// The cup receiving the lowest FromPosition is the top seed tier; brackets unnamed by any mapping sort after, by name.
|
||||
Dictionary<string, int> seedByBracket = mappings
|
||||
.GroupBy(mapping => mapping.Destination)
|
||||
.ToDictionary(group => group.Key, group => group.Min(m => m.FromPosition));
|
||||
|
||||
List<CupChampion> champions = [];
|
||||
|
||||
foreach (string? bracketName in bracketNames)
|
||||
{
|
||||
Stage? finalStage = eliminationStages
|
||||
.Where(stage => stage.BracketName == bracketName && stage.StageType == StageType.Final)
|
||||
.OrderBy(stage => stage.Order)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (finalStage is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
(Guid Winner, Guid Loser)? outcome = ResolveStageOutcome(finalStage, eliminationMatches, series);
|
||||
TeamRef? champion = outcome is null ? null : Lookup(teamsById, outcome.Value.Winner);
|
||||
|
||||
if (champion is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int seedOrder = bracketName is not null && seedByBracket.TryGetValue(bracketName, out int seed)
|
||||
? seed
|
||||
: int.MaxValue;
|
||||
|
||||
champions.Add(new CupChampion(singleBracket ? null : bracketName, seedOrder, champion));
|
||||
}
|
||||
|
||||
return [.. champions
|
||||
.OrderBy(c => c.SeedOrder)
|
||||
.ThenBy(c => c.CupName, StringComparer.Ordinal)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the division's top cup name.
|
||||
/// </summary>
|
||||
private static string? ResolveTopBracketName(
|
||||
IReadOnlyList<Stage> eliminationStages,
|
||||
IReadOnlyList<DivisionPlayoffMapping> mappings)
|
||||
{
|
||||
List<string?> bracketNames = [.. eliminationStages
|
||||
.Select(stage => stage.BracketName)
|
||||
.Distinct()];
|
||||
|
||||
if (bracketNames.Count <= 1)
|
||||
{
|
||||
return bracketNames.FirstOrDefault();
|
||||
}
|
||||
|
||||
DivisionPlayoffMapping? topMapping = mappings
|
||||
.OrderBy(mapping => mapping.FromPosition)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (topMapping is not null && bracketNames.Contains(topMapping.Destination))
|
||||
{
|
||||
return topMapping.Destination;
|
||||
}
|
||||
|
||||
return bracketNames
|
||||
.Where(name => name is not null)
|
||||
.OrderBy(name => name, StringComparer.Ordinal)
|
||||
.FirstOrDefault() ?? bracketNames[0];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a single elimination stage's decided outcome as a winner and loser pair.
|
||||
/// </summary>
|
||||
private static (Guid Winner, Guid Loser)? ResolveStageOutcome(
|
||||
Stage stage,
|
||||
IReadOnlyList<Match> eliminationMatches,
|
||||
IReadOnlyList<MatchSeries> series)
|
||||
{
|
||||
List<MatchSeries> stageSeries = [.. series.Where(s => s.StageId == stage.Id)];
|
||||
|
||||
if (stageSeries.Count > 0)
|
||||
{
|
||||
MatchSeries? decided = stageSeries.Find(s => s.WinningTeamId.HasValue);
|
||||
if (decided is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Guid seriesWinner = decided.WinningTeamId!.Value;
|
||||
Guid seriesLoser = seriesWinner == decided.HomeTeamId ? decided.VisitorTeamId : decided.HomeTeamId;
|
||||
return (seriesWinner, seriesLoser);
|
||||
}
|
||||
|
||||
Match? match = eliminationMatches
|
||||
.Where(m => m.StageId == stage.Id
|
||||
&& m.IsFinished
|
||||
&& m.WinningTeamId.HasValue
|
||||
&& m.HomeTeamId.HasValue
|
||||
&& m.VisitorTeamId.HasValue)
|
||||
.OrderByDescending(m => m.MatchDate)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Guid winner = match.WinningTeamId!.Value;
|
||||
Guid loser = winner == match.HomeTeamId!.Value ? match.VisitorTeamId!.Value : match.HomeTeamId!.Value;
|
||||
return (winner, loser);
|
||||
}
|
||||
|
||||
private static Dictionary<Guid, TeamRef> BuildTeamLookup(
|
||||
IReadOnlyList<Match> eliminationMatches,
|
||||
IReadOnlyList<MatchSeries> series)
|
||||
{
|
||||
Dictionary<Guid, TeamRef> teamsById = [];
|
||||
|
||||
foreach (Match match in eliminationMatches)
|
||||
{
|
||||
AddTeam(teamsById, match.HomeTeam);
|
||||
AddTeam(teamsById, match.VisitorTeam);
|
||||
AddTeam(teamsById, match.WinningTeam);
|
||||
}
|
||||
|
||||
foreach (MatchSeries s in series)
|
||||
{
|
||||
AddTeam(teamsById, s.HomeTeam);
|
||||
AddTeam(teamsById, s.VisitorTeam);
|
||||
AddTeam(teamsById, s.WinningTeam);
|
||||
}
|
||||
|
||||
return teamsById;
|
||||
}
|
||||
|
||||
private static void AddTeam(Dictionary<Guid, TeamRef> teamsById, Team? team)
|
||||
{
|
||||
if (team is null || teamsById.ContainsKey(team.Id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
teamsById[team.Id] = new TeamRef(team.Id, team.Name, team.LogoUrl);
|
||||
}
|
||||
|
||||
private static TeamRef? Lookup(Dictionary<Guid, TeamRef> teamsById, Guid teamId)
|
||||
{
|
||||
return teamsById.TryGetValue(teamId, out TeamRef? team) ? team : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using System;
|
||||
|
||||
namespace Application.Utils.Helper.MatchResult;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for turning a home/visitor score pair into a finished match.
|
||||
/// </summary>
|
||||
public static class MatchResultFinalizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies a decisive final result to match, deriving the winning team from whichever score is higher.
|
||||
/// </summary>
|
||||
/// <param name="match">The match to finalize. Its Stage, HomeTeam, HomeTeamId and their visitor counterparts must be loaded.</param>
|
||||
/// <param name="homeScore">The home team's final score.</param>
|
||||
/// <param name="visitorScore">The visitor team's final score.</param>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the score is tied.</exception>
|
||||
public static void ApplyResult(Match match, int homeScore, int visitorScore)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(match);
|
||||
|
||||
if (homeScore == visitorScore)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
match.Stage.StageType == StageType.Group
|
||||
? ErrorMessages.Match.GroupStageTieNotAllowed
|
||||
: ErrorMessages.Match.PlayoffTieNotAllowed);
|
||||
}
|
||||
|
||||
match.HomeScore = homeScore;
|
||||
match.VisitorScore = visitorScore;
|
||||
|
||||
bool homeWon = homeScore > visitorScore;
|
||||
match.WinningTeam = homeWon ? match.HomeTeam : match.VisitorTeam;
|
||||
match.WinningTeamId = homeWon ? match.HomeTeamId : match.VisitorTeamId;
|
||||
|
||||
match.IsFinished = true;
|
||||
match.Status = MatchStatus.Played;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Application.Utils.Helper.Playoff;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the single bracket seed order for a multi-group cross-division cup.
|
||||
/// </summary>
|
||||
public static class CrossCupGroupSeeder
|
||||
{
|
||||
/// <summary>
|
||||
/// Pools the top qualifiersPerGroup teams of each group's standings and returns them as one ordered seed list, best seed first, keeping each team's full Position (not just its id) so a caller can still apply position-range cup mappings over the pooled order.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when qualifiersPerGroup is less than 1, or when fewer than two teams are pooled across all groups, since a bracket needs at least two seeds.
|
||||
/// </exception>
|
||||
public static List<Position> PoolAndOrder(
|
||||
IEnumerable<IReadOnlyList<Position>> groupStandings,
|
||||
int qualifiersPerGroup)
|
||||
{
|
||||
if (qualifiersPerGroup < 1)
|
||||
{
|
||||
throw new InvalidOperationException(ErrorMessages.Playoff.InvalidQualifiersPerGroup(qualifiersPerGroup));
|
||||
}
|
||||
|
||||
List<Position> pooled = [.. groupStandings.SelectMany(standings => standings.Take(qualifiersPerGroup))];
|
||||
|
||||
if (pooled.Count < 2)
|
||||
{
|
||||
throw new InvalidOperationException(ErrorMessages.Playoff.NotEnoughRankedTeams);
|
||||
}
|
||||
|
||||
return [.. pooled
|
||||
.OrderByDescending(position => position.Points)
|
||||
.ThenByDescending(position => position.Wins)
|
||||
.ThenByDescending(position => position.PointsDifference)
|
||||
.ThenByDescending(position => position.PointsFor)
|
||||
.ThenBy(position => position.TeamId)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pools the top qualifiersPerGroup teams of each group's standings and returns them as one ordered seed list of team ids, best seed first.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when qualifiersPerGroup is less than 1, or when fewer than two teams are pooled across all groups, since a bracket needs at least two seeds.
|
||||
/// </exception>
|
||||
public static List<Guid> ResolveSeedOrder(
|
||||
IEnumerable<IReadOnlyList<Position>> groupStandings,
|
||||
int qualifiersPerGroup) =>
|
||||
[.. PoolAndOrder(groupStandings, qualifiersPerGroup).Select(position => position.TeamId)];
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Application.Utils.Helper.Playoff;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a division's position-range to playoff-destination mappings.
|
||||
/// </summary>
|
||||
public static class PlayoffMappingValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Throws InvalidOperationException if any range is malformed or if two ranges overlap.
|
||||
/// </summary>
|
||||
public static void Validate(IEnumerable<DivisionPlayoffMapping> mappings)
|
||||
{
|
||||
List<DivisionPlayoffMapping> ordered = [.. mappings.OrderBy(m => m.FromPosition)];
|
||||
|
||||
foreach (DivisionPlayoffMapping mapping in ordered)
|
||||
{
|
||||
if (mapping.FromPosition < 1 || mapping.ToPosition < mapping.FromPosition)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
ErrorMessages.Playoff.InvalidRange(mapping.FromPosition, mapping.ToPosition));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mapping.Destination))
|
||||
{
|
||||
throw new InvalidOperationException(ErrorMessages.Playoff.EmptyDestination);
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 1; i < ordered.Count; i++)
|
||||
{
|
||||
// Ordered by FromPosition, so an overlap exists when the current range starts at or before the previous range's end.
|
||||
if (ordered[i].FromPosition <= ordered[i - 1].ToPosition)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
ErrorMessages.Playoff.OverlappingRanges(
|
||||
ordered[i - 1].FromPosition, ordered[i - 1].ToPosition,
|
||||
ordered[i].FromPosition, ordered[i].ToPosition));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Application.Utils.Helper.Playoff;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves which teams qualify for each playoff cup from the final group-stage standings and the per-division position-range mappings.
|
||||
/// </summary>
|
||||
public static class PlayoffQualificationResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// One division's contribution to the playoffs: its final standings, best team first, and its position-range to destination mappings.
|
||||
/// </summary>
|
||||
public sealed class DivisionStandings
|
||||
{
|
||||
public required IReadOnlyList<Position> Standings { get; init; }
|
||||
public required IReadOnlyList<DivisionPlayoffMapping> Mappings { get; init; }
|
||||
public int DivisionOrder { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns, per destination cup, the ordered list of qualifying team ids gathered across every contributing division, best seed first.
|
||||
/// </summary>
|
||||
public static Dictionary<string, List<Guid>> Resolve(IEnumerable<DivisionStandings> divisions)
|
||||
{
|
||||
List<(string Destination, int Position, int DivisionOrder, Guid TeamId)> qualifiers = [];
|
||||
|
||||
foreach (DivisionStandings division in divisions)
|
||||
{
|
||||
for (int i = 0; i < division.Standings.Count; i++)
|
||||
{
|
||||
int position = i + 1;
|
||||
|
||||
DivisionPlayoffMapping? mapping = division.Mappings
|
||||
.FirstOrDefault(m => position >= m.FromPosition && position <= m.ToPosition);
|
||||
|
||||
if (mapping is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
qualifiers.Add((mapping.Destination, position, division.DivisionOrder, division.Standings[i].TeamId));
|
||||
}
|
||||
}
|
||||
|
||||
return qualifiers
|
||||
.GroupBy(q => q.Destination)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group
|
||||
.OrderBy(q => q.Position)
|
||||
.ThenBy(q => q.DivisionOrder)
|
||||
.Select(q => q.TeamId)
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Application.Utils.Constants;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Application.Utils.Helper.Playoff;
|
||||
|
||||
/// <summary>
|
||||
/// Pairs a ranked list of teams into first-round playoff matchups using the standard bracket seeding order.
|
||||
/// </summary>
|
||||
public static class PlayoffSeeder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the first-round pairs for a ranked list of teams, best seed first.
|
||||
/// </summary>
|
||||
public static List<(Guid HomeTeamId, Guid? VisitorTeamId)> SeedPairs(IReadOnlyList<Guid> orderedTeamIds)
|
||||
{
|
||||
int count = orderedTeamIds.Count;
|
||||
|
||||
if (count < 2)
|
||||
{
|
||||
throw new InvalidOperationException(ErrorMessages.Playoff.NotEnoughRankedTeams);
|
||||
}
|
||||
|
||||
int bracketSize = NextPowerOfTwo(count);
|
||||
List<Guid?> paddedSeeds = [.. orderedTeamIds.Select(id => (Guid?) id)];
|
||||
paddedSeeds.AddRange(Enumerable.Repeat((Guid?) null, bracketSize - count));
|
||||
|
||||
List<int> bracketOrder = BuildSeedOrder(bracketSize);
|
||||
|
||||
List<(Guid, Guid?)> pairs = [];
|
||||
for (int i = 0; i < bracketSize; i += 2)
|
||||
{
|
||||
Guid? first = paddedSeeds[bracketOrder[i] - 1];
|
||||
Guid? second = paddedSeeds[bracketOrder[i + 1] - 1];
|
||||
|
||||
(Guid? home, Guid? visitor) = first is null ? (second, first) : (first, second);
|
||||
|
||||
pairs.Add((home!.Value, visitor));
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Smallest power of two greater than or equal to value, the bracket size a seed pool of that many teams pads up to.
|
||||
/// </summary>
|
||||
public static int NextPowerOfTwo(int value)
|
||||
{
|
||||
int power = 1;
|
||||
while (power < value)
|
||||
{
|
||||
power *= 2;
|
||||
}
|
||||
return power;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively builds the classic bracket seed-position order.
|
||||
/// </summary>
|
||||
private static List<int> BuildSeedOrder(int size)
|
||||
{
|
||||
if (size == 1)
|
||||
{
|
||||
return [1];
|
||||
}
|
||||
|
||||
List<int> previous = BuildSeedOrder(size / 2);
|
||||
List<int> current = [];
|
||||
|
||||
foreach (int seed in previous)
|
||||
{
|
||||
current.Add(seed);
|
||||
current.Add(size + 1 - seed);
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Application.DTOs.Divisions.Response;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Application.Utils.Helper.Playoff;
|
||||
|
||||
/// <summary>
|
||||
/// Turns a division's playoff mappings into the ordered qualification ranges the public standings table paints.
|
||||
/// </summary>
|
||||
public static class QualificationRangeBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the ordered qualification ranges for a set of playoff mappings.
|
||||
/// </summary>
|
||||
/// <param name="mappings">The division's playoff mappings, in any order.</param>
|
||||
/// <returns>The ordered qualification ranges; empty when there are none.</returns>
|
||||
public static List<QualificationRangeResponse> Build(IEnumerable<DivisionPlayoffMapping>? mappings)
|
||||
{
|
||||
if (mappings is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return [.. mappings
|
||||
.OrderBy(mapping => mapping.FromPosition)
|
||||
.ThenBy(mapping => mapping.ToPosition)
|
||||
.Select((mapping, index) => new QualificationRangeResponse
|
||||
{
|
||||
FromPosition = mapping.FromPosition,
|
||||
ToPosition = mapping.ToPosition,
|
||||
CupName = mapping.Destination,
|
||||
Order = index,
|
||||
})];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
|
||||
namespace Application.Utils.Helper.RoundRobin;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a 1-based round, jornada, number to its default calendar date.
|
||||
/// </summary>
|
||||
public static class RoundCalendar
|
||||
{
|
||||
/// <summary>
|
||||
/// Weekday shift, in days, applied to a cross-division-cup group stage's jornadas relative to the regular-zone Sunday baseline.
|
||||
/// </summary>
|
||||
public const int CrossDivisionCupDayOffset = 3;
|
||||
|
||||
/// <summary>
|
||||
/// The default calendar date for a round: the first Sunday on or after startDate, advanced by one week per round.
|
||||
/// </summary>
|
||||
/// <param name="startDate">The stage's start date.</param>
|
||||
/// <param name="round">The 1-based round number.</param>
|
||||
public static DateTime SundayForRound(DateTime startDate, int round)
|
||||
{
|
||||
if (round < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(round), round, "Round numbers are 1-based.");
|
||||
}
|
||||
|
||||
int daysUntilSunday = ((int)DayOfWeek.Sunday - (int)startDate.DayOfWeek + 7) % 7;
|
||||
DateTime firstSunday = startDate.Date.AddDays(daysUntilSunday);
|
||||
|
||||
return firstSunday.AddDays(7 * (round - 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Division-aware default date for a jornada.
|
||||
/// </summary>
|
||||
/// <param name="startDate">The stage's start date, the anchor.</param>
|
||||
/// <param name="round">The 1-based round number.</param>
|
||||
/// <param name="isCrossDivisionCup">Whether the stage's division is a cross-division cup.</param>
|
||||
public static DateTime DateForRound(DateTime startDate, int round, bool isCrossDivisionCup)
|
||||
{
|
||||
DateTime sunday = SundayForRound(startDate, round);
|
||||
return isCrossDivisionCup ? sunday.AddDays(CrossDivisionCupDayOffset) : sunday;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Application.Utils.Helper.RoundRobin;
|
||||
|
||||
/// <summary>
|
||||
/// Generates a round-robin fixture using the standard circle method.
|
||||
/// </summary>
|
||||
public static class RoundRobinScheduler
|
||||
{
|
||||
/// <summary>
|
||||
/// A placeholder used internally to give an odd team count an even slot count during pairing; never appears in the returned fixture.
|
||||
/// </summary>
|
||||
private static readonly Guid Bye = Guid.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the fixture for a group of teams, tagging every pairing with its 1-based round, jornada, number.
|
||||
/// </summary>
|
||||
/// <param name="teamIds">The teams to schedule. An odd count is handled with a bye, one team sits out each round.</param>
|
||||
/// <param name="legs">How many times each pair plays: 1 for single round-robin, 2 for double, and so on.</param>
|
||||
/// <param name="random">Optional source of randomness, for deterministic tests.</param>
|
||||
/// <returns>
|
||||
/// One entry per match, each carrying the home/visitor teams and the round
|
||||
/// it is played in. Empty when fewer than two teams are supplied.
|
||||
/// </returns>
|
||||
public static List<(Guid HomeTeamId, Guid VisitorTeamId, int Round)> GenerateRounds(
|
||||
IReadOnlyList<Guid> teamIds, int legs, Random? random = null)
|
||||
{
|
||||
if (teamIds.Count < 2)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
random ??= new Random();
|
||||
|
||||
// Shuffle the seating before applying the circle method so each generation yields a different-but-valid fixture while preserving the round structure.
|
||||
List<Guid> rotation = [.. Shuffle([.. teamIds], random)];
|
||||
if (rotation.Count % 2 != 0)
|
||||
{
|
||||
rotation.Add(Bye);
|
||||
}
|
||||
|
||||
int slotCount = rotation.Count;
|
||||
int roundsPerLeg = slotCount - 1;
|
||||
|
||||
List<(Guid HomeTeamId, Guid VisitorTeamId, int Round)> fixture = [];
|
||||
|
||||
for (int leg = 0; leg < legs; leg++)
|
||||
{
|
||||
// Alternate home/away on odd legs so a double round-robin gives each pair one home and one away game.
|
||||
bool swapHomeAway = leg % 2 == 1;
|
||||
List<Guid> current = [.. rotation];
|
||||
|
||||
for (int round = 0; round < roundsPerLeg; round++)
|
||||
{
|
||||
int roundNumber = (leg * roundsPerLeg) + round + 1;
|
||||
|
||||
for (int i = 0; i < slotCount / 2; i++)
|
||||
{
|
||||
Guid a = current[i];
|
||||
Guid b = current[slotCount - 1 - i];
|
||||
if (a != Bye && b != Bye)
|
||||
{
|
||||
fixture.Add(swapHomeAway
|
||||
? (b, a, roundNumber)
|
||||
: (a, b, roundNumber));
|
||||
}
|
||||
}
|
||||
|
||||
RotateInPlace(current);
|
||||
}
|
||||
}
|
||||
|
||||
return fixture;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the fixture as a flat list of pairings, discarding the round number.
|
||||
/// </summary>
|
||||
public static List<(Guid HomeTeamId, Guid VisitorTeamId)> GenerateFixture(
|
||||
IReadOnlyList<Guid> teamIds, int legs, Random? random = null)
|
||||
{
|
||||
return [.. GenerateRounds(teamIds, legs, random)
|
||||
.Select(pairing => (pairing.HomeTeamId, pairing.VisitorTeamId))];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One round of the circle method: the first slot stays fixed, every other slot rotates one position clockwise.
|
||||
/// </summary>
|
||||
private static void RotateInPlace(List<Guid> slots)
|
||||
{
|
||||
Guid last = slots[^1];
|
||||
for (int i = slots.Count - 1; i > 1; i--)
|
||||
{
|
||||
slots[i] = slots[i - 1];
|
||||
}
|
||||
slots[1] = last;
|
||||
}
|
||||
|
||||
private static List<T> Shuffle<T>(List<T> items, Random random)
|
||||
{
|
||||
return [.. items.OrderBy(_ => random.Next())];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace Application.Utils.Helper.Series;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a best-of-N playoff series has been decided yet.
|
||||
/// </summary>
|
||||
public static class SeriesDecisionCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the winning team's id once one team has won more than half of the series' BestOf games, based on finished games recorded so far.
|
||||
/// </summary>
|
||||
public static Guid? DetermineWinner(MatchSeries series)
|
||||
{
|
||||
int winsNeeded = (series.BestOf / 2) + 1;
|
||||
|
||||
int homeWins = series.Matches.Count(game => game.IsFinished && game.WinningTeamId == series.HomeTeamId);
|
||||
if (homeWins >= winsNeeded)
|
||||
{
|
||||
return series.HomeTeamId;
|
||||
}
|
||||
|
||||
int visitorWins = series.Matches.Count(game => game.IsFinished && game.WinningTeamId == series.VisitorTeamId);
|
||||
return visitorWins >= winsNeeded ? series.VisitorTeamId : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
|
||||
namespace Application.Utils.Helper.Slug;
|
||||
|
||||
/// <summary>
|
||||
/// Composes the display string a Match's slug is generated from, fed through SlugGenerator.GenerateUniqueSlugAsync.
|
||||
/// </summary>
|
||||
public static class MatchSlugSourceBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Placeholder team name used when a match's home or visitor team is not yet assigned.
|
||||
/// </summary>
|
||||
public const string UnassignedTeamPlaceholder = "TBD";
|
||||
|
||||
/// <summary>
|
||||
/// Builds the slug source string for a match.
|
||||
/// </summary>
|
||||
/// <param name="homeTeamName">The home team's name, or null/empty when not yet assigned.</param>
|
||||
/// <param name="visitorTeamName">The visitor team's name, or null/empty when not yet assigned.</param>
|
||||
/// <param name="matchDate">The match's date.</param>
|
||||
public static string Build(string? homeTeamName, string? visitorTeamName, DateTime matchDate)
|
||||
{
|
||||
string home = string.IsNullOrWhiteSpace(homeTeamName) ? UnassignedTeamPlaceholder : homeTeamName;
|
||||
string visitor = string.IsNullOrWhiteSpace(visitorTeamName) ? UnassignedTeamPlaceholder : visitorTeamName;
|
||||
|
||||
return $"{home} vs {visitor} {matchDate:yyyy-MM-dd}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Application.Utils.Helper.Slug;
|
||||
|
||||
/// <summary>
|
||||
/// Generates lowercase, kebab-case, URL-friendly slugs from display names.
|
||||
/// </summary>
|
||||
public static partial class SlugGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts text into a lowercase, kebab-case slug.
|
||||
/// </summary>
|
||||
/// <param name="text">The display name to convert.</param>
|
||||
/// <returns>The generated slug. Never null, may be empty for input with no alphanumeric characters.</returns>
|
||||
public static string GenerateSlug(string text)
|
||||
{
|
||||
string normalized = text.Normalize(NormalizationForm.FormD);
|
||||
StringBuilder withoutAccents = new(normalized.Length);
|
||||
|
||||
foreach (char character in normalized)
|
||||
{
|
||||
if (CharUnicodeInfo.GetUnicodeCategory(character) != UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
withoutAccents.Append(character);
|
||||
}
|
||||
}
|
||||
|
||||
string lowercase = withoutAccents.ToString().Normalize(NormalizationForm.FormC).ToLowerInvariant();
|
||||
string hyphenated = NonAlphanumericRunRegex().Replace(lowercase, "-");
|
||||
|
||||
return hyphenated.Trim('-');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a slug from text and, when it collides with an existing slug, appends a numeric suffix until slugExists reports no collision.
|
||||
/// </summary>
|
||||
/// <param name="text">The display name to convert.</param>
|
||||
/// <param name="slugExists">Predicate that checks whether a candidate slug is already taken.</param>
|
||||
/// <returns>A unique slug derived from text.</returns>
|
||||
public static async Task<string> GenerateUniqueSlugAsync(string text, Func<string, Task<bool>> slugExists)
|
||||
{
|
||||
string baseSlug = GenerateSlug(text);
|
||||
string candidate = baseSlug;
|
||||
int suffix = 1;
|
||||
|
||||
while (await slugExists(candidate))
|
||||
{
|
||||
suffix++;
|
||||
candidate = $"{baseSlug}-{suffix}";
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
[GeneratedRegex("[^a-z0-9]+")]
|
||||
private static partial Regex NonAlphanumericRunRegex();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Application.Utils.Constants;
|
||||
using Application.Utils.Constants.Stage;
|
||||
|
||||
using Domain.Enums;
|
||||
|
||||
using System;
|
||||
|
||||
namespace Application.Utils.Helper.StageHelper;
|
||||
public static class StageHelper
|
||||
{
|
||||
/// <exception cref="ArgumentException">Thrown when an invalid stage type is provided.</exception>
|
||||
public static int GetMaxTeamsForStage(StageType stageType)
|
||||
{
|
||||
return stageType switch
|
||||
{
|
||||
StageType.Group => MaxTeams.Group,
|
||||
StageType.QuarterFinal => MaxTeams.QuarterFinal,
|
||||
StageType.SemiFinal => MaxTeams.SemiFinal,
|
||||
StageType.ThirdPlace => MaxTeams.ThirdPlace,
|
||||
StageType.Final => MaxTeams.Final,
|
||||
_ => throw new ArgumentException(ErrorMessages.Stage.InvalidStageType)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Application.Utils.Helper.Standings;
|
||||
|
||||
/// <summary>
|
||||
/// Standings for a single Group stage within a division.
|
||||
/// </summary>
|
||||
public class GroupStandings
|
||||
{
|
||||
/// <summary>
|
||||
/// The id of the Group stage these standings belong to.
|
||||
/// </summary>
|
||||
public required Guid StageId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The Group stage's name, used as the table label.
|
||||
/// </summary>
|
||||
public required string StageName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The ordered standings for the teams in this group.
|
||||
/// </summary>
|
||||
public required List<Position> Positions { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Application.Utils.Helper.Standings;
|
||||
|
||||
/// <summary>
|
||||
/// Computes division standings from a set of matches.
|
||||
/// </summary>
|
||||
public static class PositionCalculator
|
||||
{
|
||||
public const int DefaultPointsForWin = 2;
|
||||
public const int DefaultPointsForLoss = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Builds one Position per team that appears in at least one finished, fully-seeded match, ordered by the group-stage tiebreaker chain.
|
||||
/// </summary>
|
||||
/// <param name="matches">The zone's matches.</param>
|
||||
/// <param name="pointsForWin">Table points for a win. Defaults to 2.</param>
|
||||
/// <param name="pointsForLoss">Table points for a loss. Defaults to 1.</param>
|
||||
/// <param name="deductions">
|
||||
/// Disciplinary point deductions to subtract from the affected teams'
|
||||
/// totals before ranking. Each team's deductions are summed and subtracted
|
||||
/// from its Position.Points; the amount and reason are
|
||||
/// surfaced on Position.PointDeduction. The subtraction is
|
||||
/// raw and is never clamped — a heavy penalty may legitimately drop a
|
||||
/// team's total below zero and below other teams. Null/empty leaves every
|
||||
/// total untouched.
|
||||
/// </param>
|
||||
/// <param name="rosterTeams">
|
||||
/// Optional teams assigned to the zone. When provided, each is seeded into
|
||||
/// the table at 0-0 so every assigned team shows from the start, before any
|
||||
/// match is played. Null keeps the legacy behavior, only teams with a
|
||||
/// finished match appear.
|
||||
/// </param>
|
||||
public static List<Position> CalculatePositions(
|
||||
IEnumerable<Match> matches,
|
||||
int pointsForWin = DefaultPointsForWin,
|
||||
int pointsForLoss = DefaultPointsForLoss,
|
||||
IEnumerable<TeamPointDeduction>? deductions = null,
|
||||
IEnumerable<Team>? rosterTeams = null)
|
||||
{
|
||||
List<Match> finishedMatches = [.. matches.Where(IsSeededAndFinished)];
|
||||
|
||||
Dictionary<Guid, Position> positionsByTeamId = [];
|
||||
|
||||
// Seed every team assigned to the zone at 0-0 so the table shows all of them before any match is played, instead of an empty standings table until the first result.
|
||||
if (rosterTeams is not null)
|
||||
{
|
||||
foreach (Team team in rosterTeams)
|
||||
{
|
||||
EnsurePosition(positionsByTeamId, team);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Match match in finishedMatches)
|
||||
{
|
||||
bool homeWon = match.WinningTeamId == match.HomeTeamId;
|
||||
|
||||
ApplyResult(positionsByTeamId, match.HomeTeam!, match.HomeScore!.Value, match.VisitorScore!.Value, homeWon, pointsForWin, pointsForLoss);
|
||||
ApplyResult(positionsByTeamId, match.VisitorTeam!, match.VisitorScore.Value, match.HomeScore.Value, !homeWon, pointsForWin, pointsForLoss);
|
||||
}
|
||||
|
||||
foreach (Position position in positionsByTeamId.Values)
|
||||
{
|
||||
position.PointsDifference = position.PointsFor - position.PointsAgainst;
|
||||
}
|
||||
|
||||
// Subtract disciplinary deductions before ranking so the tiebreaker chain sees the penalised totals and the table re-orders correctly around a deducted team.
|
||||
ApplyDeductions(positionsByTeamId.Values, deductions);
|
||||
|
||||
return OrderWithTiebreakers(positionsByTeamId.Values, finishedMatches, pointsForWin, pointsForLoss);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subtracts each team's accumulated disciplinary deductions from its standings total.
|
||||
/// </summary>
|
||||
public static void ApplyDeductions(
|
||||
IEnumerable<Position> positions,
|
||||
IEnumerable<TeamPointDeduction>? deductions)
|
||||
{
|
||||
if (deductions is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<Guid, List<TeamPointDeduction>> byTeam = deductions
|
||||
.GroupBy(deduction => deduction.TeamId)
|
||||
.ToDictionary(group => group.Key, group => group.ToList());
|
||||
|
||||
if (byTeam.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (Position position in positions)
|
||||
{
|
||||
if (!byTeam.TryGetValue(position.TeamId, out List<TeamPointDeduction>? teamDeductions))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int totalDeducted = teamDeductions.Sum(deduction => deduction.Points);
|
||||
position.Points -= totalDeducted;
|
||||
position.PointDeduction = new AppliedPointDeduction
|
||||
{
|
||||
Points = totalDeducted,
|
||||
Reason = string.Join("; ", teamDeductions.Select(deduction => deduction.Reason)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSeededAndFinished(Match match)
|
||||
{
|
||||
return match.IsFinished
|
||||
&& match.HomeTeam is not null && match.VisitorTeam is not null
|
||||
&& match.HomeScore is not null && match.VisitorScore is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orders the positions applying the tiebreaker chain stepwise: teams are first sorted by PTS, then PG, then whole-zone DG.
|
||||
/// </summary>
|
||||
private static List<Position> OrderWithTiebreakers(
|
||||
IEnumerable<Position> positions,
|
||||
IReadOnlyList<Match> matches,
|
||||
int pointsForWin,
|
||||
int pointsForLoss)
|
||||
{
|
||||
List<Position> ranked = [.. positions
|
||||
.OrderByDescending(p => p.Points)
|
||||
.ThenByDescending(p => p.Wins)
|
||||
.ThenByDescending(p => p.PointsDifference)];
|
||||
|
||||
List<Position> result = [];
|
||||
|
||||
int index = 0;
|
||||
while (index < ranked.Count)
|
||||
{
|
||||
int groupEnd = index + 1;
|
||||
while (groupEnd < ranked.Count && SharesBaseCriteria(ranked[index], ranked[groupEnd]))
|
||||
{
|
||||
groupEnd++;
|
||||
}
|
||||
|
||||
List<Position> tiedGroup = ranked.GetRange(index, groupEnd - index);
|
||||
|
||||
if (tiedGroup.Count == 1)
|
||||
{
|
||||
result.Add(tiedGroup[0]);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.AddRange(BreakTie(tiedGroup, matches, pointsForWin, pointsForLoss));
|
||||
}
|
||||
|
||||
index = groupEnd;
|
||||
}
|
||||
|
||||
AssignResolvedBy(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool SharesBaseCriteria(Position a, Position b)
|
||||
{
|
||||
return a.Points == b.Points && a.Wins == b.Wins && a.PointsDifference == b.PointsDifference;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Breaks a set of teams tied on PTS/PG/DG using the head-to-head mini-table, points earned only in games among the tied set.
|
||||
/// </summary>
|
||||
private static List<Position> BreakTie(
|
||||
List<Position> tiedGroup,
|
||||
IReadOnlyList<Match> matches,
|
||||
int pointsForWin,
|
||||
int pointsForLoss)
|
||||
{
|
||||
HashSet<Guid> tiedIds = [.. tiedGroup.Select(p => p.TeamId)];
|
||||
|
||||
List<Match> h2hMatches = [.. matches.Where(m =>
|
||||
tiedIds.Contains(m.HomeTeamId!.Value) && tiedIds.Contains(m.VisitorTeamId!.Value))];
|
||||
|
||||
Dictionary<Guid, int> h2hPoints = tiedGroup.ToDictionary(p => p.TeamId, _ => 0);
|
||||
Dictionary<Guid, int> h2hDifference = tiedGroup.ToDictionary(p => p.TeamId, _ => 0);
|
||||
|
||||
foreach (Match match in h2hMatches)
|
||||
{
|
||||
Guid winnerId = match.WinningTeamId!.Value;
|
||||
Guid homeId = match.HomeTeamId!.Value;
|
||||
Guid visitorId = match.VisitorTeamId!.Value;
|
||||
|
||||
h2hPoints[winnerId] += pointsForWin;
|
||||
h2hPoints[winnerId == homeId ? visitorId : homeId] += pointsForLoss;
|
||||
|
||||
int margin = match.HomeScore!.Value - match.VisitorScore!.Value;
|
||||
h2hDifference[homeId] += margin;
|
||||
h2hDifference[visitorId] -= margin;
|
||||
}
|
||||
|
||||
// DG en H2H only applies when the tied teams played each other more than once.
|
||||
bool playedMoreThanOnce = h2hMatches.Count > 1;
|
||||
|
||||
return [.. tiedGroup
|
||||
.OrderByDescending(p => h2hPoints[p.TeamId])
|
||||
.ThenByDescending(p => playedMoreThanOnce ? h2hDifference[p.TeamId] : 0)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records, for each team, the criterion that separated it from the team ranked immediately above it, so the standings UI can show why each tie was broken.
|
||||
/// </summary>
|
||||
private static void AssignResolvedBy(List<Position> ordered)
|
||||
{
|
||||
for (int i = 1; i < ordered.Count; i++)
|
||||
{
|
||||
Position above = ordered[i - 1];
|
||||
Position current = ordered[i];
|
||||
|
||||
if (above.Points != current.Points)
|
||||
{
|
||||
current.ResolvedBy = null;
|
||||
}
|
||||
else if (above.Wins != current.Wins)
|
||||
{
|
||||
current.ResolvedBy = TiebreakerCriterion.GamesWon;
|
||||
}
|
||||
else if (above.PointsDifference != current.PointsDifference)
|
||||
{
|
||||
current.ResolvedBy = TiebreakerCriterion.PointsDifference;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Tied on all whole-zone criteria: the head-to-head chain put them in this order, and PG-in-H2H cannot cheaply be told apart from DG-in-H2H here, so HeadToHead is reported as the family.
|
||||
current.ResolvedBy = TiebreakerCriterion.HeadToHead;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the team's Position, creating a zeroed one on first touch.
|
||||
/// </summary>
|
||||
private static Position EnsurePosition(
|
||||
Dictionary<Guid, Position> positionsByTeamId,
|
||||
Team team)
|
||||
{
|
||||
if (!positionsByTeamId.TryGetValue(team.Id, out Position? position))
|
||||
{
|
||||
position = new Position
|
||||
{
|
||||
TeamId = team.Id,
|
||||
TeamName = team.Name,
|
||||
LogoUrl = team.LogoUrl,
|
||||
MatchesPlayed = 0,
|
||||
Wins = 0,
|
||||
Losses = 0,
|
||||
PointsFor = 0,
|
||||
PointsAgainst = 0,
|
||||
PointsDifference = 0,
|
||||
Points = 0,
|
||||
};
|
||||
positionsByTeamId[team.Id] = position;
|
||||
}
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
private static void ApplyResult(
|
||||
Dictionary<Guid, Position> positionsByTeamId,
|
||||
Team team,
|
||||
int pointsFor,
|
||||
int pointsAgainst,
|
||||
bool won,
|
||||
int pointsForWin,
|
||||
int pointsForLoss)
|
||||
{
|
||||
Position position = EnsurePosition(positionsByTeamId, team);
|
||||
|
||||
position.MatchesPlayed += 1;
|
||||
position.PointsFor += pointsFor;
|
||||
position.PointsAgainst += pointsAgainst;
|
||||
|
||||
if (won)
|
||||
{
|
||||
position.Wins += 1;
|
||||
position.Points += pointsForWin;
|
||||
}
|
||||
else
|
||||
{
|
||||
position.Losses += 1;
|
||||
position.Points += pointsForLoss;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Application.Utils.Helper.SubGroupDistribution;
|
||||
|
||||
/// <summary>
|
||||
/// Deals a division's roster into evenly balanced sub-groups, the shared algorithm behind rebuild and auto-distribute.
|
||||
/// </summary>
|
||||
public static class SubGroupDistribution
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum number of teams a single sub-group must receive for the split to stay playable.
|
||||
/// </summary>
|
||||
public const int MinTeamsPerSubGroup = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Whether subGroupCount groups over totalTeams teams keeps every group at or above the minimum size, treating an empty roster as always valid.
|
||||
/// </summary>
|
||||
public static bool MeetsMinimumSize(int totalTeams, int subGroupCount)
|
||||
{
|
||||
if (totalTeams == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return totalTeams / subGroupCount >= MinTeamsPerSubGroup;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Randomly deals rosterTeamIds into subGroupCount groups by round robin, so no two groups differ in size by more than one team.
|
||||
/// </summary>
|
||||
public static List<List<Guid>> Distribute(IReadOnlyList<Guid> rosterTeamIds, int subGroupCount)
|
||||
{
|
||||
List<Guid> shuffled = [.. rosterTeamIds];
|
||||
for (int i = shuffled.Count - 1; i > 0; i--)
|
||||
{
|
||||
int swapIndex = Random.Shared.Next(i + 1);
|
||||
(shuffled[swapIndex], shuffled[i]) = (shuffled[i], shuffled[swapIndex]);
|
||||
}
|
||||
|
||||
List<List<Guid>> groups = [.. Enumerable.Range(0, subGroupCount).Select(_ => new List<Guid>())];
|
||||
|
||||
for (int i = 0; i < shuffled.Count; i++)
|
||||
{
|
||||
groups[i % subGroupCount].Add(shuffled[i]);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Application.Utils.Helper.SupabaseHelper;
|
||||
|
||||
/// <summary>
|
||||
/// Narrow raw-storage boundary over the Supabase Storage client, implemented by SupabaseHelper.
|
||||
/// </summary>
|
||||
public interface ISupabaseRawStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Uploads raw content to objectPath in bucket.
|
||||
/// </summary>
|
||||
/// <param name="objectPath">The exact destination path within the bucket.</param>
|
||||
/// <param name="content">The content stream to upload.</param>
|
||||
/// <param name="bucket">Target bucket; null uses the configured SupaBase:BucketName.</param>
|
||||
Task UploadRawAsync(string objectPath, Stream content, string? bucket = null);
|
||||
|
||||
/// <summary>
|
||||
/// Lists raw objects under prefix in bucket.
|
||||
/// </summary>
|
||||
/// <param name="prefix">The bucket-relative path prefix to list.</param>
|
||||
/// <param name="bucket">Target bucket; null uses the configured SupaBase:BucketName.</param>
|
||||
Task<IReadOnlyList<SupabaseStorageEntry>> ListRawAsync(string prefix, string? bucket = null);
|
||||
|
||||
/// <summary>
|
||||
/// Removes the raw object at objectPath in bucket.
|
||||
/// </summary>
|
||||
/// <param name="objectPath">The exact object path within the bucket.</param>
|
||||
/// <param name="bucket">Target bucket; null uses the configured SupaBase:BucketName.</param>
|
||||
Task RemoveRawAsync(string objectPath, string? bucket = null);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the raw content of the object at objectPath in bucket.
|
||||
/// </summary>
|
||||
/// <param name="objectPath">The exact object path within the bucket.</param>
|
||||
/// <param name="bucket">Target bucket; null uses the configured SupaBase:BucketName.</param>
|
||||
Task<byte[]> DownloadRawAsync(string objectPath, string? bucket = null);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System;
|
||||
|
||||
namespace Application.Utils.Helper.SupabaseHelper;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal listing metadata for a raw Supabase Storage object, decoupled from the third-party Supabase.Storage.FileObject type so fakes don't need to construct it.
|
||||
/// </summary>
|
||||
public sealed record SupabaseStorageEntry(string Name, DateTimeOffset? UpdatedAt);
|
||||
@@ -0,0 +1,53 @@
|
||||
using Application.DTOs.Team.Response;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using System;
|
||||
|
||||
namespace Application.Utils.Helper.TeamProfile;
|
||||
|
||||
/// <summary>
|
||||
/// Pure projection of a Match into a TeamMatchResponse oriented from one team's perspective.
|
||||
/// </summary>
|
||||
public static class TeamMatchMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Projects match from the point of view of the team identified by teamId.
|
||||
/// </summary>
|
||||
/// <param name="match">The match to project. Its HomeTeam/VisitorTeam/Venue
|
||||
/// navigations should be loaded for names and logos to be populated.</param>
|
||||
/// <param name="teamId">The team whose perspective to orient the result to.</param>
|
||||
/// <returns>The team-oriented match projection.</returns>
|
||||
public static TeamMatchResponse Map(Match match, Guid teamId)
|
||||
{
|
||||
bool isHome = match.HomeTeamId == teamId;
|
||||
|
||||
Domain.Entities.Models.Team? opponent = isHome ? match.VisitorTeam : match.HomeTeam;
|
||||
Guid? opponentId = isHome ? match.VisitorTeamId : match.HomeTeamId;
|
||||
int? teamScore = isHome ? match.HomeScore : match.VisitorScore;
|
||||
int? opponentScore = isHome ? match.VisitorScore : match.HomeScore;
|
||||
|
||||
// Basketball has no draws, a finished match always has a winning team since a walkover sets one too, so the result is W when this team wins and L otherwise.
|
||||
string? result = null;
|
||||
if (match.IsFinished)
|
||||
{
|
||||
result = match.WinningTeamId == teamId ? "W" : "L";
|
||||
}
|
||||
|
||||
return new TeamMatchResponse
|
||||
{
|
||||
MatchId = match.Id,
|
||||
MatchDate = match.MatchDate,
|
||||
IsFinished = match.IsFinished,
|
||||
Status = match.Status.ToString(),
|
||||
IsHome = isHome,
|
||||
OpponentTeamId = opponentId ?? Guid.Empty,
|
||||
OpponentName = opponent?.Name ?? string.Empty,
|
||||
OpponentLogoUrl = opponent?.LogoUrl,
|
||||
TeamScore = teamScore,
|
||||
OpponentScore = opponentScore,
|
||||
Result = result,
|
||||
VenueName = match.Venue?.Name,
|
||||
};
|
||||
}
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
using Application.DTOs.Tournament.Response;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
using SubGroupDistributionHelper = Application.Utils.Helper.SubGroupDistribution.SubGroupDistribution;
|
||||
|
||||
namespace Application.Utils.Helper.Tournament;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for whether a tournament can be completed once it is started.
|
||||
/// </summary>
|
||||
public static class TournamentCompletabilityValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum number of teams a zone, and each cross-cup group, must have assigned for the competition it feeds to be playable or completable.
|
||||
/// </summary>
|
||||
public const int MinTeamsPerZone = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum number of HABILITADO players an enrolled team must have for the tournament to be completable.
|
||||
/// </summary>
|
||||
public const int MinPlayersPerTeam = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates every completability rule against the loaded tournament graph and returns one issue per violation.
|
||||
/// </summary>
|
||||
/// <param name="tournament">
|
||||
/// The tournament with its Divisions loaded, each division's Stages, and
|
||||
/// each stage's StageTeamMatches and PlayoffMappings loaded.
|
||||
/// </param>
|
||||
/// <param name="enrolledRegistrations">
|
||||
/// The tournament's team registrations, the enrolled teams, each ideally with
|
||||
/// its Team loaded so team names can be reported.
|
||||
/// </param>
|
||||
/// <param name="playerCountsByTeam">
|
||||
/// Each enrolled team's HABILITADO player count for this tournament, per
|
||||
/// PlayerTeamRegistration.IsHabilitado — NOT its raw
|
||||
/// registered-roster size, since a registered-but-not-yet-approved player
|
||||
/// could never legally play anyway. Used by the TeamTooFewPlayers rule.
|
||||
/// Omitted, null, skips that rule entirely — callers that don't have this
|
||||
/// data loaded are unaffected.
|
||||
/// </param>
|
||||
public static IReadOnlyList<CompletabilityIssue> Validate(
|
||||
Domain.Entities.Models.Tournament tournament,
|
||||
IReadOnlyCollection<TeamTournamentRegistration> enrolledRegistrations,
|
||||
IReadOnlyDictionary<Guid, int>? playerCountsByTeam = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tournament);
|
||||
ArgumentNullException.ThrowIfNull(enrolledRegistrations);
|
||||
|
||||
List<CompletabilityIssue> issues = [];
|
||||
|
||||
List<Division> regularDivisions =
|
||||
[.. tournament.Divisions.Where(division => !division.IsCrossDivisionCup)];
|
||||
List<Division> crossCupDivisions =
|
||||
[.. tournament.Divisions.Where(division => division.IsCrossDivisionCup)];
|
||||
|
||||
Dictionary<Guid, string> teamNames = BuildTeamNames(tournament, enrolledRegistrations);
|
||||
|
||||
Dictionary<Guid, HashSet<Guid>> assignedTeamsByDivision = regularDivisions
|
||||
.ToDictionary(division => division.Id, division => GroupStageTeamIds(division));
|
||||
|
||||
Dictionary<Guid, HashSet<Guid>> zonesByTeam = [];
|
||||
foreach ((Guid divisionId, HashSet<Guid> teamIds) in assignedTeamsByDivision)
|
||||
{
|
||||
foreach (Guid teamId in teamIds)
|
||||
{
|
||||
if (!zonesByTeam.TryGetValue(teamId, out HashSet<Guid>? zones))
|
||||
{
|
||||
zonesByTeam[teamId] = zones = [];
|
||||
}
|
||||
|
||||
zones.Add(divisionId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Division division in regularDivisions)
|
||||
{
|
||||
int assigned = assignedTeamsByDivision[division.Id].Count;
|
||||
if (assigned < MinTeamsPerZone)
|
||||
{
|
||||
issues.Add(new CompletabilityIssue
|
||||
{
|
||||
Code = CompletabilityIssueCodes.ZoneTooFewTeams,
|
||||
DivisionName = division.Name,
|
||||
AssignedTeams = assigned,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A team assigned only to the cross cup does not satisfy zone assignment.
|
||||
HashSet<Guid> enrolledTeamIds = [.. enrolledRegistrations.Select(registration => registration.TeamId)];
|
||||
foreach (Guid teamId in enrolledTeamIds.OrderBy(id => ResolveTeamName(teamNames, id)))
|
||||
{
|
||||
if (!zonesByTeam.ContainsKey(teamId))
|
||||
{
|
||||
issues.Add(new CompletabilityIssue
|
||||
{
|
||||
Code = CompletabilityIssueCodes.TeamNotAssigned,
|
||||
TeamName = ResolveTeamName(teamNames, teamId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
foreach ((Guid teamId, HashSet<Guid> zones) in zonesByTeam
|
||||
.Where(entry => entry.Value.Count > 1)
|
||||
.OrderBy(entry => ResolveTeamName(teamNames, entry.Key)))
|
||||
{
|
||||
issues.Add(new CompletabilityIssue
|
||||
{
|
||||
Code = CompletabilityIssueCodes.TeamInMultipleZones,
|
||||
TeamName = ResolveTeamName(teamNames, teamId),
|
||||
});
|
||||
}
|
||||
|
||||
// A mapping whose FromPosition exceeds the zone's assigned team count seeds a cup from positions that will never exist.
|
||||
foreach (Division division in regularDivisions)
|
||||
{
|
||||
int assigned = assignedTeamsByDivision[division.Id].Count;
|
||||
foreach (DivisionPlayoffMapping mapping in division.PlayoffMappings
|
||||
.Where(mapping => mapping.FromPosition > assigned)
|
||||
.OrderBy(mapping => mapping.FromPosition))
|
||||
{
|
||||
issues.Add(new CompletabilityIssue
|
||||
{
|
||||
Code = CompletabilityIssueCodes.PlayoffRangeExceedsTeams,
|
||||
DivisionName = division.Name,
|
||||
FromPosition = mapping.FromPosition,
|
||||
AssignedTeams = assigned,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A rebuild always deals a balanced split, so this mainly catches a hand-edited imbalance or a group left below the minimum after a manual move.
|
||||
foreach (Division division in regularDivisions)
|
||||
{
|
||||
List<Stage> subGroupStages = [.. division.Stages.Where(stage => stage.StageType == StageType.Group)];
|
||||
if (subGroupStages.Count <= 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
List<int> subGroupSizes = [.. subGroupStages
|
||||
.Select(stage => stage.StageTeamMatches.Select(match => match.TeamId).Distinct().Count())];
|
||||
|
||||
int smallestSubGroup = subGroupSizes.Min();
|
||||
int largestSubGroup = subGroupSizes.Max();
|
||||
|
||||
if (smallestSubGroup < SubGroupDistributionHelper.MinTeamsPerSubGroup || largestSubGroup - smallestSubGroup >= 2)
|
||||
{
|
||||
issues.Add(new CompletabilityIssue
|
||||
{
|
||||
Code = CompletabilityIssueCodes.SubGroupTooFewTeams,
|
||||
DivisionName = division.Name,
|
||||
AssignedTeams = smallestSubGroup,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Division crossCup in crossCupDivisions)
|
||||
{
|
||||
foreach (Stage groupStage in crossCup.Stages.Where(stage => stage.StageType == StageType.Group))
|
||||
{
|
||||
int assigned = groupStage.StageTeamMatches.Select(match => match.TeamId).Distinct().Count();
|
||||
if (assigned < MinTeamsPerZone)
|
||||
{
|
||||
issues.Add(new CompletabilityIssue
|
||||
{
|
||||
Code = CompletabilityIssueCodes.CrossCupGroupTooFewTeams,
|
||||
DivisionName = crossCup.Name,
|
||||
AssignedTeams = assigned,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every enrolled team needs at least MinPlayersPerTeam HABILITADO players, basketball's minimum to field a legal lineup without a walkover, skipped when the caller did not load player-count data.
|
||||
if (playerCountsByTeam is not null)
|
||||
{
|
||||
foreach (Guid teamId in enrolledTeamIds.OrderBy(id => ResolveTeamName(teamNames, id)))
|
||||
{
|
||||
int playerCount = playerCountsByTeam.GetValueOrDefault(teamId);
|
||||
if (playerCount < MinPlayersPerTeam)
|
||||
{
|
||||
issues.Add(new CompletabilityIssue
|
||||
{
|
||||
Code = CompletabilityIssueCodes.TeamTooFewPlayers,
|
||||
TeamName = ResolveTeamName(teamNames, teamId),
|
||||
PlayerCount = playerCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distinct ids of the teams assigned to a division's Group stages via StageTeamMatch rows.
|
||||
/// </summary>
|
||||
private static HashSet<Guid> GroupStageTeamIds(Division division)
|
||||
{
|
||||
return [.. division.Stages
|
||||
.Where(stage => stage.StageType == StageType.Group)
|
||||
.SelectMany(stage => stage.StageTeamMatches)
|
||||
.Select(match => match.TeamId)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a team-id to team-name lookup from every source that carries a Team navigation, so an issue can report a readable team name whichever rule fired.
|
||||
/// </summary>
|
||||
private static Dictionary<Guid, string> BuildTeamNames(
|
||||
Domain.Entities.Models.Tournament tournament,
|
||||
IReadOnlyCollection<TeamTournamentRegistration> enrolledRegistrations)
|
||||
{
|
||||
Dictionary<Guid, string> names = [];
|
||||
|
||||
foreach (TeamTournamentRegistration registration in enrolledRegistrations
|
||||
.Where(registration => registration.Team is not null))
|
||||
{
|
||||
names[registration.TeamId] = registration.Team!.Name;
|
||||
}
|
||||
|
||||
foreach (StageTeamMatch match in tournament.Divisions
|
||||
.SelectMany(division => division.Stages)
|
||||
.SelectMany(stage => stage.StageTeamMatches)
|
||||
.Where(match => match.Team is not null))
|
||||
{
|
||||
names[match.TeamId] = match.Team!.Name;
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a team's display name, falling back to its id when no Team navigation was loaded for it.
|
||||
/// </summary>
|
||||
private static string ResolveTeamName(Dictionary<Guid, string> teamNames, Guid teamId)
|
||||
{
|
||||
return teamNames.TryGetValue(teamId, out string? name) ? name : teamId.ToString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user