From 51e0791625f8b9f4c1e3c24be4b41e53aac084ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Fri, 10 Jul 2026 20:59:04 +0200 Subject: [PATCH 01/12] (WIP) Changes from 'definitions-loading' branch --- .../Definitions/AbstractTeamSelector.cs | 35 ++++++++++++---- .../Definitions/AbstractTeamSelectorParser.cs | 40 ------------------- .../Definitions/FinalsMatchDefinition.cs | 10 +++-- .../Definitions/MatchPlanDefinitions.cs | 26 ++++++++++-- src/Turnierplan.Core/Tournament/Tournament.cs | 12 ++++-- 5 files changed, 64 insertions(+), 59 deletions(-) diff --git a/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs b/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs index 5fc172aa..13455bb5 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs @@ -8,11 +8,30 @@ 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) + { + // TODO: Add parameter validation + + IsNthRanked = isNthRanked; + GroupIndex = groupIndex; + PlacementRank = placementRank; + OrdinalNumber = ordinalNumber; + } + + 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..08c8e38c 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelectorParser.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelectorParser.cs @@ -58,46 +58,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(); } 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/MatchPlanDefinitions.cs b/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs index f72cf5e7..5c18ac66 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Collections.Immutable; +using System.Text.Json; namespace Turnierplan.Core.Tournament.Definitions; @@ -7,15 +8,34 @@ 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 Dictionary __groupMatchDefinitions = []; + private static readonly Dictionary<(int GroupCount, int MatchCount), FinalsMatchDefinition> __finalsMatchDefinitions = []; static MatchPlanDefinitions() { + AddFinalsMatchDefinition(1, [ + new FinalsMatchDefinition.MatchDefinition(Ranked(1, 'A'), Ranked(2, 'A')) + ]); + LoadGroupMatchDefinitions(); LoadFinalsMatchDefinitions(); } + private static AbstractTeamSelector Ranked(int rank, char group) + { + return new AbstractTeamSelector(false, group - 'A', rank, null); + } + + private static AbstractTeamSelector NthRanked(int ordinal, int rank) + { + return new AbstractTeamSelector(false, null, rank, ordinal - 1); + } + + private static void AddFinalsMatchDefinition(int groupCount, ImmutableArray matches) + { + __finalsMatchDefinitions[(groupCount, matches.Length)] = new FinalsMatchDefinition(matches); + } + public static GroupMatchDefinition? GetGroupMatchDefinition(int teamCount) { return __groupMatchDefinitions.GetValueOrDefault(teamCount); diff --git a/src/Turnierplan.Core/Tournament/Tournament.cs b/src/Turnierplan.Core/Tournament/Tournament.cs index 6b8cb1fa..9f5572f7 100644 --- a/src/Turnierplan.Core/Tournament/Tournament.cs +++ b/src/Turnierplan.Core/Tournament/Tournament.cs @@ -861,7 +861,7 @@ private void GenerateFinalsMatches(FinalsRoundConfig? config, int matchIndexOffs throw new TurnierplanException($"No pre-defined first finals round configuration exists for {_groups.Count} groups and {firstFinalsRoundMatchCount} matches."); } - 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; @@ -1124,9 +1124,13 @@ 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); + if (abstractSelector.IsNthRanked) + { + // AbstractTeamSelector.OrdinalNumber is in the range 1.. while GroupResultsNthRankedSelector.OrdinalNumber is 0.. + return new GroupResultsNthRankedSelector(groupIds, abstractSelector.OrdinalNumber!.Value - 1, abstractSelector.PlacementRank); + } + + return new GroupResultsSelector(groupIds[abstractSelector.GroupIndex!.Value], abstractSelector.PlacementRank); } private sealed class GroupMatchData From 27fe23c6bccf140d3a8448e92378da036ab90ed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 12 Jul 2026 10:46:15 +0200 Subject: [PATCH 02/12] Revert "(WIP) Changes from 'definitions-loading' branch" This reverts commit 51e0791625f8b9f4c1e3c24be4b41e53aac084ce. --- .../Definitions/AbstractTeamSelector.cs | 35 ++++------------ .../Definitions/AbstractTeamSelectorParser.cs | 40 +++++++++++++++++++ .../Definitions/FinalsMatchDefinition.cs | 10 ++--- .../Definitions/MatchPlanDefinitions.cs | 26 ++---------- src/Turnierplan.Core/Tournament/Tournament.cs | 12 ++---- 5 files changed, 59 insertions(+), 64 deletions(-) diff --git a/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs b/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs index 13455bb5..5fc172aa 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs @@ -8,30 +8,11 @@ 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. /// -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) - { - // TODO: Add parameter validation - - IsNthRanked = isNthRanked; - GroupIndex = groupIndex; - PlacementRank = placementRank; - OrdinalNumber = ordinalNumber; - } - - public bool IsNthRanked { get; } - - public int? GroupIndex { get; } - - public int PlacementRank { get; } - - public int? OrdinalNumber { get; } -} +/// +/// 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); diff --git a/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelectorParser.cs b/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelectorParser.cs index 08c8e38c..a00ea169 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelectorParser.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelectorParser.cs @@ -58,6 +58,46 @@ 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(); } diff --git a/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinition.cs b/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinition.cs index d1d12dc8..2a2e49ff 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinition.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinition.cs @@ -1,16 +1,14 @@ -using System.Collections.Immutable; - -namespace Turnierplan.Core.Tournament.Definitions; +namespace Turnierplan.Core.Tournament.Definitions; public sealed record FinalsMatchDefinition { - public FinalsMatchDefinition(ImmutableArray matches) + public FinalsMatchDefinition(IEnumerable matches) { - Matches = matches; + Matches = matches.ToList(); RequiredTeamsPerGroup = Matches.SelectMany(x => new[] { x.TeamA.PlacementRank, x.TeamB.PlacementRank }).Max(); } - public ImmutableArray Matches { get; } + public IReadOnlyList Matches { get; } public int RequiredTeamsPerGroup { get; } diff --git a/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs b/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs index 5c18ac66..f72cf5e7 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs @@ -1,5 +1,4 @@ -using System.Collections.Immutable; -using System.Text.Json; +using System.Text.Json; namespace Turnierplan.Core.Tournament.Definitions; @@ -8,34 +7,15 @@ public static class MatchPlanDefinitions private const string GroupMatchDefinitionsResource = "Definitions.GroupMatchDefinitions.json"; private const string FinalsMatchDefinitionsResource = "Definitions.FinalsMatchDefinitions.json"; - private static readonly Dictionary __groupMatchDefinitions = []; - private static readonly Dictionary<(int GroupCount, int MatchCount), FinalsMatchDefinition> __finalsMatchDefinitions = []; + private static readonly Dictionary __groupMatchDefinitions = new(); + private static readonly Dictionary<(int GroupCount, int MatchCount), FinalsMatchDefinition> __finalsMatchDefinitions = new(); static MatchPlanDefinitions() { - AddFinalsMatchDefinition(1, [ - new FinalsMatchDefinition.MatchDefinition(Ranked(1, 'A'), Ranked(2, 'A')) - ]); - LoadGroupMatchDefinitions(); LoadFinalsMatchDefinitions(); } - private static AbstractTeamSelector Ranked(int rank, char group) - { - return new AbstractTeamSelector(false, group - 'A', rank, null); - } - - private static AbstractTeamSelector NthRanked(int ordinal, int rank) - { - return new AbstractTeamSelector(false, null, rank, ordinal - 1); - } - - private static void AddFinalsMatchDefinition(int groupCount, ImmutableArray matches) - { - __finalsMatchDefinitions[(groupCount, matches.Length)] = new FinalsMatchDefinition(matches); - } - public static GroupMatchDefinition? GetGroupMatchDefinition(int teamCount) { return __groupMatchDefinitions.GetValueOrDefault(teamCount); diff --git a/src/Turnierplan.Core/Tournament/Tournament.cs b/src/Turnierplan.Core/Tournament/Tournament.cs index 9f5572f7..6b8cb1fa 100644 --- a/src/Turnierplan.Core/Tournament/Tournament.cs +++ b/src/Turnierplan.Core/Tournament/Tournament.cs @@ -861,7 +861,7 @@ private void GenerateFinalsMatches(FinalsRoundConfig? config, int matchIndexOffs throw new TurnierplanException($"No pre-defined first finals round configuration exists for {_groups.Count} groups and {firstFinalsRoundMatchCount} matches."); } - for (var i = 0; i < definition.Matches.Length; i++) + for (var i = 0; i < definition.Matches.Count; i++) { var teamSelectorA = definition.Matches[i].TeamA; var teamSelectorB = definition.Matches[i].TeamB; @@ -1124,13 +1124,9 @@ private int GetNextId() private static TeamSelectorBase ConvertToSpecificInstance(AbstractTeamSelector abstractSelector, int[] groupIds) { - if (abstractSelector.IsNthRanked) - { - // AbstractTeamSelector.OrdinalNumber is in the range 1.. while GroupResultsNthRankedSelector.OrdinalNumber is 0.. - return new GroupResultsNthRankedSelector(groupIds, abstractSelector.OrdinalNumber!.Value - 1, abstractSelector.PlacementRank); - } - - return new GroupResultsSelector(groupIds[abstractSelector.GroupIndex!.Value], abstractSelector.PlacementRank); + return abstractSelector.IsNthRanked + ? new GroupResultsNthRankedSelector(groupIds, abstractSelector.OrdinalNumber!.Value, abstractSelector.PlacementRank) + : new GroupResultsSelector(groupIds[abstractSelector.GroupIndex!.Value], abstractSelector.PlacementRank); } private sealed class GroupMatchData From ddf4bb89f75508fcb46ce11b9e099af3b0d85d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 12 Jul 2026 11:19:59 +0200 Subject: [PATCH 03/12] Remove definitinos parsing from AbstractTeamSelectorParser.cs --- .../AbstractTeamSelectorParserTest.cs | 65 ++++--------------- .../Definitions/AbstractTeamSelector.cs | 35 +++++++--- .../Definitions/AbstractTeamSelectorParser.cs | 48 +------------- 3 files changed, 41 insertions(+), 107 deletions(-) diff --git a/src/Turnierplan.Core.Test.Unit/Tournament/Definitions/AbstractTeamSelectorParserTest.cs b/src/Turnierplan.Core.Test.Unit/Tournament/Definitions/AbstractTeamSelectorParserTest.cs index b769185d..584ed62f 100644 --- a/src/Turnierplan.Core.Test.Unit/Tournament/Definitions/AbstractTeamSelectorParserTest.cs +++ b/src/Turnierplan.Core.Test.Unit/Tournament/Definitions/AbstractTeamSelectorParserTest.cs @@ -7,27 +7,23 @@ 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, object expected) { // Arrange var expectedTeamSelector = (AbstractTeamSelector)expected; @@ -55,41 +51,4 @@ public void AbstractTeamSelectorParser___Parse_Invalid_Abstract_Team_Selector___ // 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..dfbb615e 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs @@ -8,11 +8,30 @@ 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) + { + // TODO: Add parameter validation -or- static 'create' methods + + IsNthRanked = isNthRanked; + GroupIndex = groupIndex; + PlacementRank = placementRank; + OrdinalNumber = ordinalNumber; + } + + 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(); } From 9fb46524eaa4d1934718d3c8d381f9aa1b2f3fbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 12 Jul 2026 11:20:57 +0200 Subject: [PATCH 04/12] Use ImmutableArray --- .../Tournament/Definitions/FinalsMatchDefinition.cs | 10 ++++++---- .../Tournament/Definitions/GroupMatchDefinition.cs | 8 ++++---- src/Turnierplan.Core/Tournament/Tournament.cs | 12 ++++++++---- 3 files changed, 18 insertions(+), 12 deletions(-) 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/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/Tournament.cs b/src/Turnierplan.Core/Tournament/Tournament.cs index 6b8cb1fa..9f5572f7 100644 --- a/src/Turnierplan.Core/Tournament/Tournament.cs +++ b/src/Turnierplan.Core/Tournament/Tournament.cs @@ -861,7 +861,7 @@ private void GenerateFinalsMatches(FinalsRoundConfig? config, int matchIndexOffs throw new TurnierplanException($"No pre-defined first finals round configuration exists for {_groups.Count} groups and {firstFinalsRoundMatchCount} matches."); } - 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; @@ -1124,9 +1124,13 @@ 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); + if (abstractSelector.IsNthRanked) + { + // AbstractTeamSelector.OrdinalNumber is in the range 1.. while GroupResultsNthRankedSelector.OrdinalNumber is 0.. + return new GroupResultsNthRankedSelector(groupIds, abstractSelector.OrdinalNumber!.Value - 1, abstractSelector.PlacementRank); + } + + return new GroupResultsSelector(groupIds[abstractSelector.GroupIndex!.Value], abstractSelector.PlacementRank); } private sealed class GroupMatchData From 05869ad28035bc809d5d8d653c85d0892e808349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 12 Jul 2026 11:21:14 +0200 Subject: [PATCH 05/12] Migrate group match definitions to C# --- .../Definitions/GroupMatchDefinitions.json | 242 ------------------ .../Definitions/MatchPlanDefinitions.cs | 174 +++++++++---- src/Turnierplan.Core/Turnierplan.Core.csproj | 3 - 3 files changed, 129 insertions(+), 290 deletions(-) delete mode 100644 src/Turnierplan.Core/Tournament/Definitions/GroupMatchDefinitions.json 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..14250c8e 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs @@ -1,19 +1,91 @@ -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); }); + }); + + // Add finals definitions... + + (__groupMatchDefinitions, __finalsMatchDefinitions) = builder.Build(); } public static GroupMatchDefinition? GetGroupMatchDefinition(int teamCount) @@ -36,60 +108,72 @@ 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) + // Add builder methods here... + + public (IReadOnlyDictionary __groupMatchDefinitions, IReadOnlyDictionary<(int GroupCount, int MatchCount), FinalsMatchDefinition> __finalsMatchDefinitions) Build() { - var matchBlocks = model.MatchBlocks.Select(x => - { - 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); - }); - - return new GroupMatchDefinition.MatchBlock([..matches]); - }); - __groupMatchDefinitions[model.TeamCount] = new GroupMatchDefinition(matchBlocks); + 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]); - - return new FinalsMatchDefinition.MatchDefinition(teamA, teamB); - }) - .ToList(); - __finalsMatchDefinitions[(model.GroupCount, MatchCount: matches.Count)] = new FinalsMatchDefinition(matches); + var blockBuilder = new GroupMatchDefinitionMatchBlockBuilder(_groupTeamCount); + configure(blockBuilder); + _blocks.Add(new GroupMatchDefinition.MatchBlock(blockBuilder.Matches)); } } - private sealed record GroupMatchDefinitionJsonModel(int TeamCount, int[][][] MatchBlocks); + private sealed class GroupMatchDefinitionMatchBlockBuilder + { + private readonly int _groupTeamCount; + private readonly List _matches = []; + + public GroupMatchDefinitionMatchBlockBuilder(int groupTeamCount) + { + _groupTeamCount = groupTeamCount; + } + + public ImmutableArray Matches => [.._matches]; - private sealed record FinalsMatchDefinitionJsonModel(int GroupCount, string[][] MatchDefinitions); + 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)); + } + } } diff --git a/src/Turnierplan.Core/Turnierplan.Core.csproj b/src/Turnierplan.Core/Turnierplan.Core.csproj index 8b8c5a06..8dd46165 100644 --- a/src/Turnierplan.Core/Turnierplan.Core.csproj +++ b/src/Turnierplan.Core/Turnierplan.Core.csproj @@ -11,8 +11,5 @@ Definitions.FinalsMatchDefinitions.json - - Definitions.GroupMatchDefinitions.json - From ab546bd563f92132410fe21e59bcc0808e9a9e88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 12 Jul 2026 11:43:49 +0200 Subject: [PATCH 06/12] Implement builder pattern for finals matches --- .../Definitions/FinalsMatchDefinitions.json | 40 ----- .../Definitions/MatchPlanDefinitions.cs | 156 +++++++++++++++++- 2 files changed, 154 insertions(+), 42 deletions(-) diff --git a/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinitions.json b/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinitions.json index de146543..fb89b693 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinitions.json +++ b/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinitions.json @@ -1,44 +1,4 @@ [ - { - "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" ], diff --git a/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs b/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs index 14250c8e..97f5fd6c 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs @@ -83,7 +83,39 @@ static MatchPlanDefinitions() g.Block(b => { b.Match(1, 6); b.Match(4, 7); b.Match(9, 5); b.Match(3, 8); }); }); - // Add finals definitions... + 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'); + }); (__groupMatchDefinitions, __finalsMatchDefinitions) = builder.Build(); } @@ -122,10 +154,25 @@ public void Group(int teamCount, Action configure) var builder = new GroupMatchDefinitionBuilder(teamCount); configure(builder); + _groupMatchDefinitions[teamCount] = new GroupMatchDefinition(builder.Blocks); } - // Add builder methods here... + public void Finals(int groupCount, Action configure) + { + var builder = new FinalsMatchDefinitionBuilder(groupCount); + configure(builder); + + var matches = builder.BuildMatches(); + var key = (GroupCount: groupCount, MatchCount: matches.Length); + + if (_finalsMatchDefinitions.ContainsKey(key)) + { + throw new InvalidOperationException($"A finals match definition for {groupCount} groups and {matches.Length} matches already exists."); + } + + _finalsMatchDefinitions[key] = new FinalsMatchDefinition(matches); + } public (IReadOnlyDictionary __groupMatchDefinitions, IReadOnlyDictionary<(int GroupCount, int MatchCount), FinalsMatchDefinition> __finalsMatchDefinitions) Build() { @@ -176,4 +223,109 @@ public void Match(int teamA, int teamB) _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 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'; + + 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}."); + } + + SetOpponent(new AbstractTeamSelector(false, null, position, ordinal)); + + 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; + } + } + } } From 04cb47646d9bc5897e1a47807f87ea2d28d06cb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 12 Jul 2026 11:56:04 +0200 Subject: [PATCH 07/12] Revert unintended change --- src/Turnierplan.Core/Tournament/Tournament.cs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/Turnierplan.Core/Tournament/Tournament.cs b/src/Turnierplan.Core/Tournament/Tournament.cs index 9f5572f7..31482ea7 100644 --- a/src/Turnierplan.Core/Tournament/Tournament.cs +++ b/src/Turnierplan.Core/Tournament/Tournament.cs @@ -1124,13 +1124,9 @@ private int GetNextId() private static TeamSelectorBase ConvertToSpecificInstance(AbstractTeamSelector abstractSelector, int[] groupIds) { - if (abstractSelector.IsNthRanked) - { - // AbstractTeamSelector.OrdinalNumber is in the range 1.. while GroupResultsNthRankedSelector.OrdinalNumber is 0.. - return new GroupResultsNthRankedSelector(groupIds, abstractSelector.OrdinalNumber!.Value - 1, abstractSelector.PlacementRank); - } - - return new GroupResultsSelector(groupIds[abstractSelector.GroupIndex!.Value], abstractSelector.PlacementRank); + return abstractSelector.IsNthRanked + ? new GroupResultsNthRankedSelector(groupIds, abstractSelector.OrdinalNumber!.Value, abstractSelector.PlacementRank) + : new GroupResultsSelector(groupIds[abstractSelector.GroupIndex!.Value], abstractSelector.PlacementRank); } private sealed class GroupMatchData From 8519904acb2a3c028700e23fb0c7c15f6b4ec4aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 12 Jul 2026 11:56:32 +0200 Subject: [PATCH 08/12] Remove comment --- .../Tournament/Definitions/AbstractTeamSelector.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs b/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs index dfbb615e..a75a5198 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs @@ -19,8 +19,6 @@ public sealed record AbstractTeamSelector /// internal AbstractTeamSelector(bool isNthRanked, int? groupIndex, int placementRank, int? ordinalNumber) { - // TODO: Add parameter validation -or- static 'create' methods - IsNthRanked = isNthRanked; GroupIndex = groupIndex; PlacementRank = placementRank; From ecd0c40d5b2a9db9d4ac83f2c64b2acd73613cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 12 Jul 2026 11:56:47 +0200 Subject: [PATCH 09/12] Add remaining finals definitions & fix bug --- .../Definitions/FinalsMatchDefinitions.json | 47 ------------------- .../Definitions/MatchPlanDefinitions.cs | 43 ++++++++++++++++- src/Turnierplan.Core/Turnierplan.Core.csproj | 6 --- 3 files changed, 42 insertions(+), 54 deletions(-) delete mode 100644 src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinitions.json diff --git a/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinitions.json b/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinitions.json deleted file mode 100644 index fb89b693..00000000 --- a/src/Turnierplan.Core/Tournament/Definitions/FinalsMatchDefinitions.json +++ /dev/null @@ -1,47 +0,0 @@ -[ - { - "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/MatchPlanDefinitions.cs b/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs index 97f5fd6c..c851f12d 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/MatchPlanDefinitions.cs @@ -117,6 +117,46 @@ static MatchPlanDefinitions() 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(); } @@ -279,7 +319,8 @@ public FinalsMatchDefinitionMatchBuilder NthRanked(int ordinal, int position) throw new ArgumentException($"The ordinal must be between 1 and the group count {_groupCount}."); } - SetOpponent(new AbstractTeamSelector(false, null, position, ordinal)); + // 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; } diff --git a/src/Turnierplan.Core/Turnierplan.Core.csproj b/src/Turnierplan.Core/Turnierplan.Core.csproj index 8dd46165..856b7268 100644 --- a/src/Turnierplan.Core/Turnierplan.Core.csproj +++ b/src/Turnierplan.Core/Turnierplan.Core.csproj @@ -6,10 +6,4 @@ enable enable - - - - Definitions.FinalsMatchDefinitions.json - - From 9606cbbf50a79ee235be2aa711d46fa2891146fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Mon, 13 Jul 2026 19:44:32 +0200 Subject: [PATCH 10/12] typed AbstractTeamSelector --- .../Definitions/AbstractTeamSelectorParserTest.cs | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/Turnierplan.Core.Test.Unit/Tournament/Definitions/AbstractTeamSelectorParserTest.cs b/src/Turnierplan.Core.Test.Unit/Tournament/Definitions/AbstractTeamSelectorParserTest.cs index 584ed62f..d34a9838 100644 --- a/src/Turnierplan.Core.Test.Unit/Tournament/Definitions/AbstractTeamSelectorParserTest.cs +++ b/src/Turnierplan.Core.Test.Unit/Tournament/Definitions/AbstractTeamSelectorParserTest.cs @@ -7,7 +7,7 @@ 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", new AbstractTeamSelector(false, 0, 1, null) }, { "2.0", new AbstractTeamSelector(false, 0, 2, null) }, @@ -23,16 +23,10 @@ public sealed class AbstractTeamSelectorParserTest [Theory] [MemberData(nameof(ParseAbstractTeamSelectorTestData))] - public void AbstractTeamSelectorParser___Parse_Valid_Abstract_Team_Selector___Works_As_Expected(string input, 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] @@ -45,10 +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."); } } From 06a59cd7e3a5c8bbda121eb491bbb37f7cee1e49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Mon, 13 Jul 2026 19:46:57 +0200 Subject: [PATCH 11/12] Add [MemberNotNullWhen] --- .../Tournament/Definitions/AbstractTeamSelector.cs | 5 ++++- src/Turnierplan.Core/Tournament/Tournament.cs | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs b/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs index a75a5198..532f51b3 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; @@ -25,6 +26,8 @@ internal AbstractTeamSelector(bool isNthRanked, int? groupIndex, int placementRa OrdinalNumber = ordinalNumber; } + [MemberNotNullWhen(true, nameof(OrdinalNumber))] + [MemberNotNullWhen(false, nameof(GroupIndex))] public bool IsNthRanked { get; } public int? GroupIndex { get; } diff --git a/src/Turnierplan.Core/Tournament/Tournament.cs b/src/Turnierplan.Core/Tournament/Tournament.cs index 5a0d6b18..fbf24925 100644 --- a/src/Turnierplan.Core/Tournament/Tournament.cs +++ b/src/Turnierplan.Core/Tournament/Tournament.cs @@ -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 From 897d74fd55020b27e3ae9ffb40de2dcea1161928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Mon, 13 Jul 2026 20:36:51 +0200 Subject: [PATCH 12/12] Add parameter validation --- .../Definitions/AbstractTeamSelector.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs b/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs index 532f51b3..72b37e7c 100644 --- a/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs +++ b/src/Turnierplan.Core/Tournament/Definitions/AbstractTeamSelector.cs @@ -20,6 +20,27 @@ public sealed record AbstractTeamSelector /// 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;