Sube carpetas API, API.Tests, Application y Domain del Backend
This commit is contained in:
@@ -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,
|
||||
})];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user