diff --git a/src/Turnierplan.Core.Test.Unit/Tournament/Definitions/AbstractTeamSelectorParserTest.cs b/src/Turnierplan.Core.Test.Unit/Tournament/Definitions/AbstractTeamSelectorParserTest.cs index b769185d..d34a9838 100644 --- a/src/Turnierplan.Core.Test.Unit/Tournament/Definitions/AbstractTeamSelectorParserTest.cs +++ b/src/Turnierplan.Core.Test.Unit/Tournament/Definitions/AbstractTeamSelectorParserTest.cs @@ -7,36 +7,26 @@ namespace Turnierplan.Core.Test.Unit.Tournament.Definitions; public sealed class AbstractTeamSelectorParserTest { - public static readonly TheoryData ParseAbstractTeamSelectorTestData = new() + public static readonly TheoryData ParseAbstractTeamSelectorTestData = new() { - { "1.0", "A1", new AbstractTeamSelector(false, 0, 1, null) }, - { "2.0", "A2", new AbstractTeamSelector(false, 0, 2, null) }, - { "1.1", "B1", new AbstractTeamSelector(false, 1, 1, null) }, - { "3.2", "C3", new AbstractTeamSelector(false, 2, 3, null) }, - { "4.25", "Z4", new AbstractTeamSelector(false, 25, 4, null) }, - - { "0B1", "1B1", new AbstractTeamSelector(true, null, 1, 0) }, - { "2B1", "3B1", new AbstractTeamSelector(true, null, 1, 2) }, - { "1B3", "2B3", new AbstractTeamSelector(true, null, 3, 1) }, - { "4B4", "5B4", new AbstractTeamSelector(true, null, 4, 4) } + { "1.0", new AbstractTeamSelector(false, 0, 1, null) }, + { "2.0", new AbstractTeamSelector(false, 0, 2, null) }, + { "1.1", new AbstractTeamSelector(false, 1, 1, null) }, + { "3.2", new AbstractTeamSelector(false, 2, 3, null) }, + { "4.25", new AbstractTeamSelector(false, 25, 4, null) }, + + { "0B1", new AbstractTeamSelector(true, null, 1, 0) }, + { "2B1", new AbstractTeamSelector(true, null, 1, 2) }, + { "1B3", new AbstractTeamSelector(true, null, 3, 1) }, + { "4B4", new AbstractTeamSelector(true, null, 4, 4) } }; [Theory] [MemberData(nameof(ParseAbstractTeamSelectorTestData))] - public void AbstractTeamSelectorParser___Parse_Valid_Abstract_Team_Selector___Works_As_Expected(string input, -#pragma warning disable xUnit1026 // Theory methods should use all of their parameters - string _, -#pragma warning restore xUnit1026 - object expected) + public void AbstractTeamSelectorParser___Parse_Valid_Abstract_Team_Selector___Works_As_Expected(string input, AbstractTeamSelector expected) { - // Arrange - var expectedTeamSelector = (AbstractTeamSelector)expected; - - // Act var parsed = AbstractTeamSelectorParser.ParseAbstractTeamSelector(input); - - // Assert - parsed.Should().BeEquivalentTo(expectedTeamSelector); + parsed.Should().BeEquivalentTo(expected); } [Theory] @@ -49,47 +39,7 @@ public void AbstractTeamSelectorParser___Parse_Valid_Abstract_Team_Selector___Wo [InlineData("0C2")] public void AbstractTeamSelectorParser___Parse_Invalid_Abstract_Team_Selector___Throws_Exception(string input) { - // Act var func = void () => AbstractTeamSelectorParser.ParseAbstractTeamSelector(input); - - // Assert func.Should().ThrowExactly().WithMessage($"The abstract team selector '{input}' is not valid."); } - - [Theory] - [MemberData(nameof(ParseAbstractTeamSelectorTestData))] - public void AbstractTeamSelectorParser___Parse_Valid_Abstract_Team_Selector_With_Definitions_Format___Works_As_Expected( -#pragma warning disable xUnit1026 // Theory methods should use all of their parameters - string _, -#pragma warning restore xUnit1026 - string input, - object expected) - { - // Arrange - var expectedTeamSelector = (AbstractTeamSelector)expected; - - // Act - var parsed = AbstractTeamSelectorParser.ParseAbstractTeamSelectorFromDefinitionFormat(input); - - // Assert - parsed.Should().BeEquivalentTo(expectedTeamSelector); - } - - [Theory] - [InlineData("A")] - [InlineData("0")] - [InlineData("A0")] - [InlineData("00")] - [InlineData("_0")] - [InlineData("0B2")] - [InlineData("2B0")] - [InlineData("0C2")] - public void AbstractTeamSelectorParser___Parse_Invalid_Abstract_Team_Selector_With_Definitions_Format___Throws_Exception(string input) - { - // Act - var func = void () => AbstractTeamSelectorParser.ParseAbstractTeamSelectorFromDefinitionFormat(input); - - // Assert - func.Should().ThrowExactly().WithMessage($"Invalid abstract team selector: '{input}'"); - } } diff --git a/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs b/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs index 5fc172aa..72b37e7c 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs @@ -1,4 +1,5 @@ -using Turnierplan.Core.Tournament.TeamSelectors; +using System.Diagnostics.CodeAnalysis; +using Turnierplan.Core.Tournament.TeamSelectors; namespace Turnierplan.Core.Tournament.Definitions; @@ -8,11 +9,51 @@ namespace Turnierplan.Core.Tournament.Definitions; /// created yet and that therefore do not have a specific id. Abstract team selectors are used to configure the /// generation of tournaments because the group ids are only generated during the generation process. /// -/// -/// If is true, and -/// must be specified and denote the team selector parameters as used in . -/// If is false, and -/// must be specified and will be used to generate a team selector to select the team with that position in a specific -/// group in the tournament. -/// -public sealed record AbstractTeamSelector(bool IsNthRanked, int? GroupIndex, int PlacementRank, int? OrdinalNumber); +public sealed record AbstractTeamSelector +{ + /// + /// If is true, and + /// must be specified and denote the team selector parameters as used in . + /// If is false, and + /// must be specified and will be used to generate a team selector to select the team with that position in a specific + /// group in the tournament. + /// + internal AbstractTeamSelector(bool isNthRanked, int? groupIndex, int placementRank, int? ordinalNumber) + { + if (isNthRanked) + { + if (groupIndex is not null || ordinalNumber is null) + { + throw new ArgumentException($"If '{nameof(isNthRanked)}' is true, '{nameof(groupIndex)}' must be null and '{nameof(ordinalNumber)}' must be non-null."); + } + + ArgumentOutOfRangeException.ThrowIfLessThan(placementRank, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(ordinalNumber.Value, 0); + } + else + { + if (groupIndex is null || ordinalNumber is not null) + { + throw new ArgumentException($"If '{nameof(isNthRanked)}' is false, '{nameof(groupIndex)}' must be non-null and '{nameof(ordinalNumber)}' must be null."); + } + + ArgumentOutOfRangeException.ThrowIfLessThan(placementRank, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(groupIndex.Value, 0); + } + + IsNthRanked = isNthRanked; + GroupIndex = groupIndex; + PlacementRank = placementRank; + OrdinalNumber = ordinalNumber; + } + + [MemberNotNullWhen(true, nameof(OrdinalNumber))] + [MemberNotNullWhen(false, nameof(GroupIndex))] + public bool IsNthRanked { get; } + + public int? GroupIndex { get; } + + public int PlacementRank { get; } + + public int? OrdinalNumber { get; } +} diff --git a/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelectorParser.cs b/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelectorParser.cs index a00ea169..22ffb225 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelectorParser.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelectorParser.cs @@ -4,10 +4,6 @@ namespace Turnierplan.Core.Tournament.Definitions; -/// -/// The FinalsMatchDefinitions.json uses a modified string representation for the abstract team selectors because the -/// definitions file should be easily readable and the "standard" string representation format is not particularly intuitive. -/// public static partial class AbstractTeamSelectorParser { public static AbstractTeamSelector ParseAbstractTeamSelector(string? source) @@ -25,7 +21,7 @@ public static bool TryParseAbstractTeamSelector(string? source, [NotNullWhen(tru return false; } - var regex = AbstractTeamSelectorExternalFormatRegex(); + var regex = AbstractTeamSelectorRegex(); var match = regex.Match(source); if (!match.Success) @@ -58,46 +54,6 @@ public static bool TryParseAbstractTeamSelector(string? source, [NotNullWhen(tru return false; } - internal static AbstractTeamSelector ParseAbstractTeamSelectorFromDefinitionFormat(string source) - { - var regex = AbstractTeamSelectorDefinitionFormatRegex(); - var match = regex.Match(source); - - if (!match.Success) - { - throw new InvalidOperationException($"Invalid abstract team selector: '{source}'"); - } - - if (match.Groups["GroupRef"].Success) - { - if (int.TryParse(match.Groups["PlacementRank"].Value, out var placementRank)) - { - var groupRef = match.Groups["GroupRef"].Value.Single(); - - if (char.IsLetter(groupRef) && placementRank >= 1) - { - var upper = char.ToUpper(groupRef); - var groupIndex = upper - 'A'; - - return new AbstractTeamSelector(false, groupIndex, placementRank, null); - } - } - } - else if (int.TryParse(match.Groups["OrdinalNumber"].Value, out var ordinalNumber) - && int.TryParse(match.Groups["PlacementRank"].Value, out var placementRank) - && ordinalNumber >= 1 - && placementRank >= 1) - { - // Subtract 1 from ordinal number because in definitions json the referenced rankings are provided on a 1.. range. - return new AbstractTeamSelector(true, null, placementRank, ordinalNumber - 1); - } - - throw new InvalidOperationException($"Invalid abstract team selector: '{source}'"); - } - [GeneratedRegex(@"^(?:(?\d+)\.(?\d+)|(?\d)B(?\d))$")] - private static partial Regex AbstractTeamSelectorExternalFormatRegex(); - - [GeneratedRegex(@"^(?:(?[A-Z])(?\d)|(?\d)B(?\d))$")] - private static partial Regex AbstractTeamSelectorDefinitionFormatRegex(); + private static partial Regex AbstractTeamSelectorRegex(); } diff --git a/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinition.cs b/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinition.cs index 2a2e49ff..d1d12dc8 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinition.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinition.cs @@ -1,14 +1,16 @@ -namespace Turnierplan.Core.Tournament.Definitions; +using System.Collections.Immutable; + +namespace Turnierplan.Core.Tournament.Definitions; public sealed record FinalsMatchDefinition { - public FinalsMatchDefinition(IEnumerable matches) + public FinalsMatchDefinition(ImmutableArray matches) { - Matches = matches.ToList(); + Matches = matches; RequiredTeamsPerGroup = Matches.SelectMany(x => new[] { x.TeamA.PlacementRank, x.TeamB.PlacementRank }).Max(); } - public IReadOnlyList Matches { get; } + public ImmutableArray Matches { get; } public int RequiredTeamsPerGroup { get; } diff --git a/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinitions.json b/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinitions.json deleted file mode 100644 index de146543..00000000 --- a/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinitions.json +++ /dev/null @@ -1,87 +0,0 @@ -[ - { - "MatchDefinitions": [ - [ "A1", "A2" ] - ], - "GroupCount": 1 - }, - { - "MatchDefinitions": [ - [ "A1", "B1" ] - ], - "GroupCount": 2 - }, - { - "MatchDefinitions": [ - [ "A1", "A4" ], - [ "A2", "A3" ] - ], - "GroupCount": 1 - }, - { - "MatchDefinitions": [ - [ "A1", "B2" ], - [ "B1", "A2" ] - ], - "GroupCount": 2 - }, - { - "MatchDefinitions": [ - [ "A1", "B1" ], - [ "C1", "1B2" ] - ], - "GroupCount": 3 - }, - { - "MatchDefinitions": [ - [ "A1", "B1" ], - [ "C1", "D1" ] - ], - "GroupCount": 4 - }, - { - "MatchDefinitions": [ - [ "A1", "B4" ], - [ "A2", "B3" ], - [ "A3", "B2" ], - [ "A4", "B1" ] - ], - "GroupCount": 2 - }, - { - "MatchDefinitions": [ - [ "A1", "1B3" ], - [ "B1", "2B3" ], - [ "A2", "C2" ], - [ "B2", "C1" ] - ], - "GroupCount": 3 - }, - { - "MatchDefinitions": [ - [ "A1", "C2" ], - [ "B1", "D2" ], - [ "C1", "A2" ], - [ "D1", "B2" ] - ], - "GroupCount": 4 - }, - { - "MatchDefinitions": [ - [ "A1", "B1" ], - [ "C1", "1B2" ], - [ "D1", "2B2" ], - [ "E1", "3B2" ] - ], - "GroupCount": 5 - }, - { - "MatchDefinitions": [ - [ "A1", "B1" ], - [ "C1", "D1" ], - [ "E1", "1B2" ], - [ "F1", "2B2" ] - ], - "GroupCount": 6 - } -] diff --git a/src/Turnierplan.Core/Tournament/Definitions/GroupMatchDefinition.cs b/src/Turnierplan.Core/Tournament/Definitions/GroupMatchDefinition.cs index 6aff9b11..b297c626 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/GroupMatchDefinition.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/GroupMatchDefinition.cs @@ -4,14 +4,14 @@ namespace Turnierplan.Core.Tournament.Definitions; public sealed record GroupMatchDefinition { - public GroupMatchDefinition(IEnumerable matchBlocks) + public GroupMatchDefinition(ImmutableArray matchBlocks) { - MatchBlocks = matchBlocks.ToList(); + MatchBlocks = matchBlocks; } - public IReadOnlyList MatchBlocks { get; } + public ImmutableArray MatchBlocks { get; } - public int BlockCount => MatchBlocks.Count; + public int BlockCount => MatchBlocks.Length; public sealed record MatchBlock(ImmutableArray Matches); diff --git a/src/Turnierplan.Core/Tournament/Definitions/GroupMatchDefinitions.json b/src/Turnierplan.Core/Tournament/Definitions/GroupMatchDefinitions.json deleted file mode 100644 index 4f7acb9d..00000000 --- a/src/Turnierplan.Core/Tournament/Definitions/GroupMatchDefinitions.json +++ /dev/null @@ -1,242 +0,0 @@ -[ - { - "MatchBlocks": [ - [ - [ 1, 2 ] - ] - ], - "TeamCount": 2 - }, - { - "MatchBlocks": [ - [ - [ 1, 2 ] - ], - [ - [ 2, 3 ] - ], - [ - [ 3, 1 ] - ] - ], - "TeamCount": 3 - }, - { - "MatchBlocks": [ - [ - [ 1, 2 ], - [ 3, 4 ] - ], - [ - [ 1, 3 ], - [ 2, 4 ] - ], - [ - [ 4, 1 ], - [ 2, 3 ] - ] - ], - "TeamCount": 4 - }, - { - "MatchBlocks": [ - [ - [ 1, 2 ], - [ 3, 4 ] - ], - [ - [ 5, 1 ], - [ 2, 3 ] - ], - [ - [ 4, 5 ], - [ 3, 1 ] - ], - [ - [ 2, 5 ], - [ 1, 4 ] - ], - [ - [ 5, 3 ], - [ 4, 2 ] - ] - ], - "TeamCount": 5 - }, - { - "MatchBlocks": [ - [ - [ 1, 2 ], - [ 3, 4 ], - [ 5, 6 ] - ], - [ - [ 3, 1 ], - [ 6, 2 ], - [ 4, 5 ] - ], - [ - [ 1, 6 ], - [ 5, 3 ], - [ 4, 2 ] - ], - [ - [ 5, 1 ], - [ 6, 4 ], - [ 2, 3 ] - ], - [ - [ 1, 4 ], - [ 2, 5 ], - [ 3, 6 ] - ] - ], - "TeamCount": 6 - }, - { - "MatchBlocks": [ - [ - [ 1, 2 ], - [ 3, 4 ], - [ 5, 6 ] - ], - [ - [ 4, 7 ], - [ 1, 6 ], - [ 5, 3 ] - ], - [ - [ 6, 2 ], - [ 7, 5 ], - [ 3, 1 ] - ], - [ - [ 4, 5 ], - [ 2, 3 ], - [ 7, 1 ] - ], - [ - [ 3, 6 ], - [ 1, 4 ], - [ 2, 7 ] - ], - [ - [ 5, 1 ], - [ 6, 7 ], - [ 4, 2 ] - ], - [ - [ 7, 3 ], - [ 2, 5 ], - [ 6, 4 ] - ] - ], - "TeamCount": 7 - }, - { - "MatchBlocks": [ - [ - [ 1, 2 ], - [ 3, 4 ], - [ 5, 6 ], - [ 7, 8 ] - ], - [ - [ 1, 4 ], - [ 6, 2 ], - [ 3, 8 ], - [ 7, 5 ] - ], - [ - [ 1, 6 ], - [ 8, 4 ], - [ 2, 7 ], - [ 5, 3 ] - ], - [ - [ 1, 8 ], - [ 6, 7 ], - [ 4, 5 ], - [ 2, 3 ] - ], - [ - [ 7, 1 ], - [ 5, 8 ], - [ 3, 6 ], - [ 4, 2 ] - ], - [ - [ 5, 1 ], - [ 7, 3 ], - [ 8, 2 ], - [ 6, 4 ] - ], - [ - [ 3, 1 ], - [ 2, 5 ], - [ 4, 7 ], - [ 8, 6 ] - ] - ], - "TeamCount": 8 - }, - { - "MatchBlocks": [ - [ - [ 1, 2 ], - [ 3, 4 ], - [ 5, 6 ], - [ 7, 8 ] - ], - [ - [ 9, 1 ], - [ 2, 3 ], - [ 4, 5 ], - [ 6, 7 ] - ], - [ - [ 8, 9 ], - [ 3, 1 ], - [ 6, 4 ], - [ 2, 5 ] - ], - [ - [ 9, 7 ], - [ 1, 4 ], - [ 5, 8 ], - [ 2, 7 ] - ], - [ - [ 3, 6 ], - [ 4, 9 ], - [ 1, 8 ], - [ 6, 2 ] - ], - [ - [ 5, 3 ], - [ 8, 4 ], - [ 7, 1 ], - [ 9, 3 ] - ], - [ - [ 4, 2 ], - [ 7, 5 ], - [ 8, 6 ], - [ 2, 9 ] - ], - [ - [ 5, 1 ], - [ 7, 3 ], - [ 6, 9 ], - [ 8, 2 ] - ], - [ - [ 1, 6 ], - [ 4, 7 ], - [ 9, 5 ], - [ 3, 8 ] - ] - ], - "TeamCount": 9 - } -] diff --git a/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs b/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs index f72cf5e7..c851f12d 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs @@ -1,19 +1,163 @@ -using System.Text.Json; +using System.Collections.Immutable; namespace Turnierplan.Core.Tournament.Definitions; public static class MatchPlanDefinitions { - private const string GroupMatchDefinitionsResource = "Definitions.GroupMatchDefinitions.json"; - private const string FinalsMatchDefinitionsResource = "Definitions.FinalsMatchDefinitions.json"; - - private static readonly Dictionary __groupMatchDefinitions = new(); - private static readonly Dictionary<(int GroupCount, int MatchCount), FinalsMatchDefinition> __finalsMatchDefinitions = new(); + private static readonly IReadOnlyDictionary __groupMatchDefinitions; + private static readonly IReadOnlyDictionary<(int GroupCount, int MatchCount), FinalsMatchDefinition> __finalsMatchDefinitions; static MatchPlanDefinitions() { - LoadGroupMatchDefinitions(); - LoadFinalsMatchDefinitions(); + var builder = new DefinitionsBuilder(); + + builder.Group(2, g => + { + g.Block(b => b.Match(1, 2)); + }); + + builder.Group(3, g => + { + g.Block(b => b.Match(1, 2)); + g.Block(b => b.Match(2, 3)); + g.Block(b => b.Match(3, 1)); + }); + + builder.Group(4, g => + { + g.Block(b => { b.Match(1, 2); b.Match(3, 4); }); + g.Block(b => { b.Match(1, 3); b.Match(2, 4); }); + g.Block(b => { b.Match(4, 1); b.Match(2, 3); }); + }); + + builder.Group(5, g => + { + g.Block(b => { b.Match(1, 2); b.Match(3, 4); }); + g.Block(b => { b.Match(5, 1); b.Match(2, 3); }); + g.Block(b => { b.Match(4, 5); b.Match(3, 1); }); + g.Block(b => { b.Match(2, 5); b.Match(1, 4); }); + g.Block(b => { b.Match(5, 3); b.Match(4, 2); }); + }); + + builder.Group(6, g => + { + g.Block(b => { b.Match(1, 2); b.Match(3, 4); b.Match(5, 6); }); + g.Block(b => { b.Match(3, 1); b.Match(6, 2); b.Match(4, 5); }); + g.Block(b => { b.Match(1, 6); b.Match(5, 3); b.Match(4, 2); }); + g.Block(b => { b.Match(5, 1); b.Match(6, 4); b.Match(2, 3); }); + g.Block(b => { b.Match(1, 4); b.Match(2, 5); b.Match(3, 6); }); + }); + + builder.Group(7, g => + { + g.Block(b => { b.Match(1, 2); b.Match(3, 4); b.Match(5, 6); }); + g.Block(b => { b.Match(4, 7); b.Match(1, 6); b.Match(5, 3); }); + g.Block(b => { b.Match(6, 2); b.Match(7, 5); b.Match(3, 1); }); + g.Block(b => { b.Match(4, 5); b.Match(2, 3); b.Match(7, 1); }); + g.Block(b => { b.Match(3, 6); b.Match(1, 4); b.Match(2, 7); }); + g.Block(b => { b.Match(5, 1); b.Match(6, 7); b.Match(4, 2); }); + g.Block(b => { b.Match(7, 3); b.Match(2, 5); b.Match(6, 4); }); + }); + + builder.Group(8, g => + { + g.Block(b => { b.Match(1, 2); b.Match(3, 4); b.Match(5, 6); b.Match(7, 8); }); + g.Block(b => { b.Match(1, 4); b.Match(6, 2); b.Match(3, 8); b.Match(7, 5); }); + g.Block(b => { b.Match(1, 6); b.Match(8, 4); b.Match(2, 7); b.Match(5, 3); }); + g.Block(b => { b.Match(1, 8); b.Match(6, 7); b.Match(4, 5); b.Match(2, 3); }); + g.Block(b => { b.Match(7, 1); b.Match(5, 8); b.Match(3, 6); b.Match(4, 2); }); + g.Block(b => { b.Match(5, 1); b.Match(7, 3); b.Match(8, 2); b.Match(6, 4); }); + g.Block(b => { b.Match(3, 1); b.Match(2, 5); b.Match(4, 7); b.Match(8, 6); }); + }); + + builder.Group(9, g => + { + g.Block(b => { b.Match(1, 2); b.Match(3, 4); b.Match(5, 6); b.Match(7, 8); }); + g.Block(b => { b.Match(9, 1); b.Match(2, 3); b.Match(4, 5); b.Match(6, 7); }); + g.Block(b => { b.Match(8, 9); b.Match(3, 1); b.Match(6, 4); b.Match(2, 5); }); + g.Block(b => { b.Match(9, 7); b.Match(1, 4); b.Match(5, 8); b.Match(2, 7); }); + g.Block(b => { b.Match(3, 6); b.Match(4, 9); b.Match(1, 8); b.Match(6, 2); }); + g.Block(b => { b.Match(5, 3); b.Match(8, 4); b.Match(7, 1); b.Match(9, 3); }); + g.Block(b => { b.Match(4, 2); b.Match(7, 5); b.Match(8, 6); b.Match(2, 9); }); + g.Block(b => { b.Match(5, 1); b.Match(7, 3); b.Match(6, 9); b.Match(8, 2); }); + g.Block(b => { b.Match(1, 6); b.Match(4, 7); b.Match(9, 5); b.Match(3, 8); }); + }); + + builder.Finals(1, f => + { + f.Match().Group(1, 'A').Against().Group(2, 'A'); + }); + + builder.Finals(2, f => + { + f.Match().Group(1, 'A').Against().Group(1, 'B'); + }); + + builder.Finals(1, f => + { + f.Match().Group(1, 'A').Against().Group(4, 'A'); + f.Match().Group(2, 'A').Against().Group(3, 'A'); + }); + + builder.Finals(2, f => + { + f.Match().Group(1, 'A').Against().Group(2, 'B'); + f.Match().Group(1, 'B').Against().Group(2, 'A'); + }); + + builder.Finals(3, f => + { + f.Match().Group(1, 'A').Against().Group(1, 'B'); + f.Match().Group(1, 'C').Against().NthRanked(1, 2); + }); + + builder.Finals(4, f => + { + f.Match().Group(1, 'A').Against().Group(1, 'B'); + f.Match().Group(1, 'C').Against().Group(1, 'D'); + }); + + builder.Finals(2, f => + { + f.Match().Group(1, 'A').Against().Group(4, 'B'); + f.Match().Group(2, 'A').Against().Group(3, 'B'); + f.Match().Group(3, 'A').Against().Group(2, 'B'); + f.Match().Group(4, 'A').Against().Group(1, 'B'); + }); + + builder.Finals(3, f => + { + f.Match().Group(1, 'A').Against().NthRanked(1, 3); + f.Match().Group(1, 'B').Against().NthRanked(2, 3); + f.Match().Group(2, 'A').Against().Group(2, 'C'); + f.Match().Group(2, 'B').Against().Group(1, 'C'); + }); + + builder.Finals(4, f => + { + f.Match().Group(1, 'A').Against().Group(2, 'C'); + f.Match().Group(1, 'B').Against().Group(2, 'D'); + f.Match().Group(1, 'C').Against().Group(2, 'A'); + f.Match().Group(1, 'D').Against().Group(2, 'B'); + }); + + builder.Finals(5, f => + { + f.Match().Group(1, 'A').Against().Group(1, 'B'); + f.Match().Group(1, 'C').Against().NthRanked(1, 2); + f.Match().Group(1, 'D').Against().NthRanked(2, 2); + f.Match().Group(1, 'E').Against().NthRanked(3, 2); + }); + + builder.Finals(6, f => + { + f.Match().Group(1, 'A').Against().Group(1, 'B'); + f.Match().Group(1, 'C').Against().Group(1, 'D'); + f.Match().Group(1, 'E').Against().NthRanked(1, 2); + f.Match().Group(1, 'F').Against().NthRanked(2, 2); + }); + + (__groupMatchDefinitions, __finalsMatchDefinitions) = builder.Build(); } public static GroupMatchDefinition? GetGroupMatchDefinition(int teamCount) @@ -36,60 +180,193 @@ static MatchPlanDefinitions() return __finalsMatchDefinitions.Select(x => (x.Key.GroupCount, x.Key.MatchCount, Definition: x.Value)); } - private static void LoadGroupMatchDefinitions() + private sealed class DefinitionsBuilder { - var stream = typeof(MatchPlanDefinitions).Assembly.GetManifestResourceStream(GroupMatchDefinitionsResource); - var result = JsonSerializer.Deserialize>(stream!); + private readonly Dictionary _groupMatchDefinitions = []; + private readonly Dictionary<(int GroupCount, int MatchCount), FinalsMatchDefinition> _finalsMatchDefinitions = []; - if (result is null) + public void Group(int teamCount, Action configure) { - throw new IOException($"Failed to parse '{GroupMatchDefinitionsResource}'."); + if (_groupMatchDefinitions.ContainsKey(teamCount)) + { + throw new InvalidOperationException($"A group match definition for {teamCount} teams already exists."); + } + + var builder = new GroupMatchDefinitionBuilder(teamCount); + configure(builder); + + _groupMatchDefinitions[teamCount] = new GroupMatchDefinition(builder.Blocks); } - foreach (var model in result) + public void Finals(int groupCount, Action configure) { - var matchBlocks = model.MatchBlocks.Select(x => + var builder = new FinalsMatchDefinitionBuilder(groupCount); + configure(builder); + + var matches = builder.BuildMatches(); + var key = (GroupCount: groupCount, MatchCount: matches.Length); + + if (_finalsMatchDefinitions.ContainsKey(key)) { - var matches = x.Select(y => - { - // Subtract 1 from the indices because in definitions json the indices are provided on a 1.. range. - var teamIdA = y[0] - 1; - var teamIdB = y[1] - 1; - return new GroupMatchDefinition.MatchDefinition(teamIdA, teamIdB); - }); + throw new InvalidOperationException($"A finals match definition for {groupCount} groups and {matches.Length} matches already exists."); + } - return new GroupMatchDefinition.MatchBlock([..matches]); - }); - __groupMatchDefinitions[model.TeamCount] = new GroupMatchDefinition(matchBlocks); + _finalsMatchDefinitions[key] = new FinalsMatchDefinition(matches); + } + + public (IReadOnlyDictionary __groupMatchDefinitions, IReadOnlyDictionary<(int GroupCount, int MatchCount), FinalsMatchDefinition> __finalsMatchDefinitions) Build() + { + return (_groupMatchDefinitions.AsReadOnly(), _finalsMatchDefinitions.AsReadOnly()); } } - private static void LoadFinalsMatchDefinitions() + private sealed class GroupMatchDefinitionBuilder { - var stream = typeof(MatchPlanDefinitions).Assembly.GetManifestResourceStream(FinalsMatchDefinitionsResource); - var result = JsonSerializer.Deserialize>(stream!); + private readonly int _groupTeamCount; + private readonly List _blocks = []; - if (result is null) + public GroupMatchDefinitionBuilder(int groupTeamCount) { - throw new IOException($"Failed to parse '{FinalsMatchDefinitionsResource}'."); + _groupTeamCount = groupTeamCount; } - foreach (var model in result) + public ImmutableArray Blocks => [.._blocks]; + + public void Block(Action configure) { - var matches = model.MatchDefinitions - .Select(x => - { - var teamA = AbstractTeamSelectorParser.ParseAbstractTeamSelectorFromDefinitionFormat(x[0]); - var teamB = AbstractTeamSelectorParser.ParseAbstractTeamSelectorFromDefinitionFormat(x[1]); + var blockBuilder = new GroupMatchDefinitionMatchBlockBuilder(_groupTeamCount); + configure(blockBuilder); + _blocks.Add(new GroupMatchDefinition.MatchBlock(blockBuilder.Matches)); + } + } - return new FinalsMatchDefinition.MatchDefinition(teamA, teamB); - }) - .ToList(); - __finalsMatchDefinitions[(model.GroupCount, MatchCount: matches.Count)] = new FinalsMatchDefinition(matches); + private sealed class GroupMatchDefinitionMatchBlockBuilder + { + private readonly int _groupTeamCount; + private readonly List _matches = []; + + public GroupMatchDefinitionMatchBlockBuilder(int groupTeamCount) + { + _groupTeamCount = groupTeamCount; + } + + public ImmutableArray Matches => [.._matches]; + + public void Match(int teamA, int teamB) + { + if (teamA < 1 || teamB < 1 || teamA > _groupTeamCount || teamB > _groupTeamCount || teamA == teamB) + { + throw new ArgumentException($"Teams A and B must both be between 1 and {_groupTeamCount} and they may not equal each other."); + } + + // Subtract 1 so the caller can specify 1..n which is more intuitive than 0..(n-1) + _matches.Add(new GroupMatchDefinition.MatchDefinition(teamA - 1, teamB - 1)); + } + } + + private sealed record FinalsMatchDefinitionBuilder + { + private readonly int _groupCount; + private readonly List _matchBuilders = []; + + public FinalsMatchDefinitionBuilder(int groupCount) + { + _groupCount = groupCount; + } + + public FinalsMatchDefinitionMatchBuilder Match() + { + var builder = new FinalsMatchDefinitionMatchBuilder(_groupCount); + _matchBuilders.Add(builder); + return builder; + } + + public ImmutableArray BuildMatches() + { + return [.._matchBuilders.Select(x => x.Build())]; } } - private sealed record GroupMatchDefinitionJsonModel(int TeamCount, int[][][] MatchBlocks); + private sealed record FinalsMatchDefinitionMatchBuilder + { + private readonly int _groupCount; + private AbstractTeamSelector? _teamA, _teamB; + private bool _againstCalled; + + public FinalsMatchDefinitionMatchBuilder(int groupCount) + { + _groupCount = groupCount; + } + + public FinalsMatchDefinitionMatchBuilder Group(int position, char group) + { + var groupIndex = group - 'A'; - private sealed record FinalsMatchDefinitionJsonModel(int GroupCount, string[][] MatchDefinitions); + if (groupIndex < 0 || groupIndex >= _groupCount) + { + throw new ArgumentException($"The group must be between 'A' and '{(char)('A' + _groupCount - 1)}'."); + } + + SetOpponent(new AbstractTeamSelector(false, groupIndex, position, null)); + + return this; + } + + public FinalsMatchDefinitionMatchBuilder NthRanked(int ordinal, int position) + { + if (ordinal < 1 || ordinal > _groupCount) + { + throw new ArgumentException($"The ordinal must be between 1 and the group count {_groupCount}."); + } + + // Subtract 1 from ordinal so the caller can specify 1..n which is more intuitive than 0..(n-1) + SetOpponent(new AbstractTeamSelector(true, null, position, ordinal - 1)); + + return this; + } + + public FinalsMatchDefinitionMatchBuilder Against() + { + if (_againstCalled) + { + throw new InvalidOperationException($"The '{nameof(Against)}()' method may only be called once."); + } + + _againstCalled = true; + + return this; + } + + public FinalsMatchDefinition.MatchDefinition Build() + { + if (_teamA is null || _teamB is null) + { + throw new InvalidOperationException("One or both opponents are not defined."); + } + + return new FinalsMatchDefinition.MatchDefinition(_teamA, _teamB); + } + + private void SetOpponent(AbstractTeamSelector team) + { + if (_againstCalled) + { + if (_teamB is not null) + { + throw new InvalidOperationException("Any of the two opponents may only be set once."); + } + + _teamB = team; + } + else + { + if (_teamA is not null) + { + throw new InvalidOperationException("Any of the two opponents may only be set once."); + } + + _teamA = team; + } + } + } } diff --git a/src/Turnierplan.Core/Tournament/Tournament.cs b/src/Turnierplan.Core/Tournament/Tournament.cs index c5993126..fbf24925 100644 --- a/src/Turnierplan.Core/Tournament/Tournament.cs +++ b/src/Turnierplan.Core/Tournament/Tournament.cs @@ -867,7 +867,7 @@ private void GenerateFinalsMatches(FinalsRoundConfig? config, int matchIndexOffs throw new TurnierplanException($"At least one group does not contain enough participants (present: {lowestCount}, required: {definition.RequiredTeamsPerGroup}) for the pre-defined first finals round."); } - for (var i = 0; i < definition.Matches.Count; i++) + for (var i = 0; i < definition.Matches.Length; i++) { var teamSelectorA = definition.Matches[i].TeamA; var teamSelectorB = definition.Matches[i].TeamB; @@ -1131,8 +1131,8 @@ private int GetNextId() private static TeamSelectorBase ConvertToSpecificInstance(AbstractTeamSelector abstractSelector, int[] groupIds) { return abstractSelector.IsNthRanked - ? new GroupResultsNthRankedSelector(groupIds, abstractSelector.OrdinalNumber!.Value, abstractSelector.PlacementRank) - : new GroupResultsSelector(groupIds[abstractSelector.GroupIndex!.Value], abstractSelector.PlacementRank); + ? new GroupResultsNthRankedSelector(groupIds, abstractSelector.OrdinalNumber.Value, abstractSelector.PlacementRank) + : new GroupResultsSelector(groupIds[abstractSelector.GroupIndex.Value], abstractSelector.PlacementRank); } private sealed class GroupMatchData diff --git a/src/Turnierplan.Core/Turnierplan.Core.csproj b/src/Turnierplan.Core/Turnierplan.Core.csproj index 8b8c5a06..856b7268 100644 --- a/src/Turnierplan.Core/Turnierplan.Core.csproj +++ b/src/Turnierplan.Core/Turnierplan.Core.csproj @@ -6,13 +6,4 @@ enable enable - - - - Definitions.FinalsMatchDefinitions.json - - - Definitions.GroupMatchDefinitions.json - -