diff --git a/PadelClub/Views/Round/RoundView.swift b/PadelClub/Views/Round/RoundView.swift index 904880b..119b016 100644 --- a/PadelClub/Views/Round/RoundView.swift +++ b/PadelClub/Views/Round/RoundView.swift @@ -80,9 +80,11 @@ struct RoundView: View { } } - if let disabledMatchesCount, disabledMatchesCount > 0 { - let bracketTip = BracketEditTip(nextRoundName: upperRound.round.nextRound()?.roundTitle()) - TipView(bracketTip).tipStyle(tint: .green, asSection: true) + if let disabledMatchesCount { + if disabledMatchesCount > 0 { + let bracketTip = BracketEditTip(nextRoundName: upperRound.round.nextRound()?.roundTitle()) + TipView(bracketTip).tipStyle(tint: .green, asSection: true) + } let leftToPlay = (RoundRule.numberOfMatches(forRoundIndex: upperRound.round.index) - disabledMatchesCount) @@ -94,7 +96,9 @@ struct RoundView: View { Text("Match\(leftToPlay.pluralSuffix) à jouer en \(upperRound.title)") } } footer: { - Text("\(disabledMatchesCount) match\(disabledMatchesCount.pluralSuffix) désactivé\(disabledMatchesCount.pluralSuffix) automatiquement") + if disabledMatchesCount > 0 { + Text("\(disabledMatchesCount) match\(disabledMatchesCount.pluralSuffix) désactivé\(disabledMatchesCount.pluralSuffix) automatiquement") + } } } } diff --git a/PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift b/PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift index 690340c..d53e10b 100644 --- a/PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift +++ b/PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift @@ -11,33 +11,195 @@ import PadelClubData struct HeadManagerView: View { - @Environment(Tournament.self) private var tournament: Tournament @EnvironmentObject private var dataStore: DataStore @Environment(\.dismiss) var dismiss - @Binding var initialSeedRound: Int - @Binding var initialSeedCount: Int - + let teamsInBracket: Int + let heads: Int + let initialSeedRepartition: [Int]? + let result: ([Int]) -> Void + + @State private var seedRepartition: [Int] + @State private var selectedSeedRound: Int? = nil + + init(teamsInBracket: Int, heads: Int, initialSeedRepartition: [Int], result: @escaping ([Int]) -> Void) { + self.teamsInBracket = teamsInBracket + self.heads = heads + self.initialSeedRepartition = initialSeedRepartition + self.result = result + + if initialSeedRepartition.isEmpty == false { + _seedRepartition = .init(wrappedValue: initialSeedRepartition) + _selectedSeedRound = .init(wrappedValue: initialSeedRepartition.firstIndex(where: { $0 > 0 })) + } else { + let seedRepartition = Self.place(heads: heads, teamsInBracket: teamsInBracket, initialSeedRound: nil) + _seedRepartition = .init(wrappedValue: seedRepartition) + _selectedSeedRound = .init(wrappedValue: seedRepartition.firstIndex(where: { $0 > 0 })) + } + } + + static func leftToPlace(heads: Int, teamsPerRound: [Int]) -> Int { + let re = heads - teamsPerRound.reduce(0, +) + return re + } + + static func place(heads: Int, teamsInBracket: Int, initialSeedRound: Int?) -> [Int] { + var teamsPerRound: [Int] = [] + let dimension = RoundRule.teamsInFirstRound(forTeams: teamsInBracket) + /* + si 32 = 32, ok si N < 32 alors on mets le max en 16 - ce qui reste à mettre en 32 + */ + + var startingRound = RoundRule.numberOfRounds(forTeams: dimension) - 1 + if let initialSeedRound, initialSeedRound > 0 { + teamsPerRound = Array(repeating: 0, count: initialSeedRound) + startingRound = initialSeedRound + } else { + if dimension != teamsInBracket { + startingRound -= 1 + } + + teamsPerRound = Array(repeating: 0, count: startingRound) + } + while leftToPlace(heads: heads, teamsPerRound: teamsPerRound) > 0 { + // maxAssignable: On retire toutes les équipes placées dans les tours précédents, pondérées par leur propagation (puissance du tour) + let headsLeft = heads - teamsPerRound.reduce(0, +) + // Calculate how many teams from previous rounds propagate to this round + let currentRound = teamsPerRound.count + var previousTeams = 0 + for (i, teams) in teamsPerRound.enumerated() { + previousTeams += teams * (1 << (currentRound - i)) + } + let totalAvailable = RoundRule.numberOfMatches(forRoundIndex: currentRound) * 2 + let maxAssignable = max(0, totalAvailable - previousTeams) + var valueToAppend = min(max(0, headsLeft), maxAssignable) + + if headsLeft - maxAssignable > 0 { + let theory = valueToAppend - (headsLeft - maxAssignable) + if theory > 0 && maxAssignable - theory == 0 { + valueToAppend = theory + } else { + let lastValue = teamsPerRound.last ?? 0 + var newValueToAppend = theory + if theory > maxAssignable || theory < 0 { + newValueToAppend = valueToAppend / 2 + } + valueToAppend = lastValue > 0 ? lastValue : newValueToAppend + } + } + teamsPerRound.append(valueToAppend) + + } + return teamsPerRound + } + + var leftToPlace: Int { + Self.leftToPlace(heads: heads, teamsPerRound: seedRepartition) + } + var body: some View { List { Section { - Picker(selection: $initialSeedRound) { - ForEach((0...10)) { - Text(RoundRule.roundName(fromRoundIndex: $0)).tag($0) + Picker(selection: $selectedSeedRound) { + Text("Choisir").tag(nil as Int?) + ForEach(seedRepartition.indices, id: \.self) { idx in + Text(RoundRule.roundName(fromRoundIndex: idx)).tag(idx) } } label: { Text("Tour contenant la meilleure tête de série") } - .onChange(of: initialSeedRound) { - initialSeedCount = RoundRule.numberOfMatches(forRoundIndex: initialSeedRound) + .onChange(of: selectedSeedRound) { + seedRepartition = Self.place(heads: heads, teamsInBracket: teamsInBracket, initialSeedRound: selectedSeedRound) } - + } footer: { + FooterButtonView("remise à zéro") { + selectedSeedRound = nil + } + } + + Section { + LabeledContent { + Text(teamsInBracket.formatted()) + } label: { + Text("Effectif du tableau") + } + + LabeledContent { - StepperView(count: $initialSeedCount, minimum: 0, maximum: RoundRule.numberOfMatches(forRoundIndex: initialSeedRound)) + Text(leftToPlace.formatted()) } label: { - Text("Nombre de tête de série") + Text("Restant à placer") + } + } + + Section { +// +// LabeledContent { +// StepperView(count: $initialSeedCount, minimum: 0, maximum: RoundRule.numberOfMatches(forRoundIndex: initialSeedRound)) +// } label: { +// Text("Nombre de tête de série") +// } +// + ForEach(seedRepartition.sorted().indices, id: \.self) { index in + SeedStepperRowView(count: $seedRepartition[index], roundIndex: index, max: leftToPlace + seedRepartition[index]) + } + } + + if leftToPlace > 0 { + RowButtonView("Ajouter une manche") { + while leftToPlace > 0 { + let headsLeft = heads - seedRepartition.reduce(0, +) + let lastValue = seedRepartition.last ?? 0 + let maxAssignable = RoundRule.numberOfMatches(forRoundIndex: seedRepartition.count - 1) * 2 - lastValue + var valueToAppend = min(max(0, headsLeft), maxAssignable * 2) + + if headsLeft - maxAssignable > 0 { + valueToAppend = valueToAppend - (headsLeft - maxAssignable) + } + print("Appending to seedRepartition: headsLeft=\(headsLeft), maxAssignable=\(maxAssignable), valueToAppend=\(valueToAppend), current seedRepartition=\(seedRepartition)") + seedRepartition.append(valueToAppend) + } } } } .navigationTitle("Têtes de série") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + ButtonValidateView(title: "Valider") { + self.result(seedRepartition) + dismiss() + } + } + + ToolbarItem(placement: .topBarLeading) { + Button("Annuler") { + dismiss() + } + } + } +// .onChange(of: seedRepartition) { old, new in +// if modifiyingSeedRound == false { +// let minCount = min(old.count, new.count) +// if let idx = (0.. new[$0] }) { +// seedRepartition = Array(new.prefix(idx+1)) +// } +// } +// } + } +} + +private struct SeedStepperRowView: View { + @Binding var count: Int + var roundIndex: Int + var max: Int + + var body: some View { + LabeledContent { + HStack { + StepperView(count: $count, minimum: 0, maximum: min(RoundRule.numberOfMatches(forRoundIndex: roundIndex) * 2, max)) + } + } label: { + Text("Équipes en \(RoundRule.roundName(fromRoundIndex: roundIndex))") + } } } + diff --git a/PadelClub/Views/Tournament/Screen/TableStructureView.swift b/PadelClub/Views/Tournament/Screen/TableStructureView.swift index f7ff58a..21f0542 100644 --- a/PadelClub/Views/Tournament/Screen/TableStructureView.swift +++ b/PadelClub/Views/Tournament/Screen/TableStructureView.swift @@ -27,7 +27,9 @@ struct TableStructureView: View { @State private var selectedTournament: Tournament? @State private var initialSeedCount: Int = 0 @State private var initialSeedRound: Int = 0 - + @State private var showSeedRepartition: Bool = false + @State private var seedRepartition: [Int] = [] + func displayWarning() -> Bool { let unsortedTeamsCount = tournament.unsortedTeamsCount() return tournament.shouldWarnOnlineRegistrationUpdates() && teamCount != tournament.teamCount && (tournament.teamCount <= unsortedTeamsCount || teamCount <= unsortedTeamsCount) @@ -64,6 +66,10 @@ struct TableStructureView: View { max(teamCount - groupStageCount * teamsPerGroupStage, 0) } + var tf: Int { + max(teamCount - teamsFromGroupStages + qualifiedFromGroupStage + (groupStageCount > 0 ? groupStageAdditionalQualified : 0), 0) + } + init(tournament: Tournament) { self.tournament = tournament _teamCount = .init(wrappedValue: tournament.teamCount) @@ -92,7 +98,14 @@ struct TableStructureView: View { Text("Préréglage") } .disabled(selectedTournament != nil) + } footer: { + Text(structurePreset.localizedDescriptionStructurePresetTitle()) + } + .onChange(of: structurePreset) { + _updatePreset() + } + Section { NavigationLink { TournamentSelectorView(selectedTournament: $selectedTournament) .environment(tournament) @@ -103,24 +116,10 @@ struct TableStructureView: View { Text("À partir d'un tournoi existant") } } - - NavigationLink { - HeadManagerView(initialSeedRound: $initialSeedRound, initialSeedCount: $initialSeedCount) - .environment(tournament) - } label: { - Text("Configuration des têtes de série") - } - .disabled(selectedTournament != nil) - - } footer: { - Text(structurePreset.localizedDescriptionStructurePresetTitle()) } .onChange(of: selectedTournament) { _updatePreset() } - .onChange(of: structurePreset) { - _updatePreset() - } } Section { @@ -240,7 +239,6 @@ struct TableStructureView: View { } Section { - let tf = max(teamCount - teamsFromGroupStages + qualifiedFromGroupStage + (groupStageCount > 0 ? groupStageAdditionalQualified : 0), 0) if groupStageCount > 0 { if structurePreset != .doubleGroupStage { LabeledContent { @@ -268,15 +266,13 @@ struct TableStructureView: View { Text("Attention !").foregroundStyle(.red) } } - LabeledContent { - Text(tf.formatted()) - } label: { - Text("Effectif") - } - LabeledContent { - Text(RoundRule.teamsInFirstRound(forTeams: tf).formatted()) - } label: { - Text("Dimension") + + if groupStageCount > 0 { + LabeledContent { + Text(tf.formatted()) + } label: { + Text("Effectif") + } } } else { LabeledContent { @@ -307,6 +303,27 @@ struct TableStructureView: View { } } + if tournament.state() != .build { + Section { + LabeledContent { + Image(systemName: seedRepartition.isEmpty ? "xmark" : "checkmark") + } label: { + FooterButtonView("Configuration du tableau") { + showSeedRepartition = true + } + .disabled(selectedTournament != nil) + } + } footer: { + if seedRepartition.isEmpty { + Text("Aucune répartition n'a été indiqué, vous devrez réserver ou placer les têtes de séries dans le tableau manuellement.") + } else { + FooterButtonView("Supprimer la configuration", role: .destructive) { + seedRepartition = [] + } + } + } + } + if tournament.rounds().isEmpty && tournament.state() == .build { Section { RowButtonView("Ajouter un tableau", role: .destructive) { @@ -327,13 +344,13 @@ struct TableStructureView: View { Section { RowButtonView("Reconstruire les poules", role:.destructive) { - _save(rebuildEverything: false) + await _save(rebuildEverything: false) } } Section { RowButtonView("Tout refaire", role: .destructive) { - _save(rebuildEverything: true) + await _save(rebuildEverything: true) } } @@ -360,6 +377,13 @@ struct TableStructureView: View { updatedElements.remove(.teamCount) } } + .sheet(isPresented: $showSeedRepartition, content: { + NavigationStack { + HeadManagerView(teamsInBracket: tf, heads: tsPure, initialSeedRepartition: seedRepartition) { seedRepartition in + self.seedRepartition = seedRepartition + } + } + }) .onChange(of: groupStageCount) { if groupStageCount != tournament.groupStageCount { updatedElements.insert(.groupStageCount) @@ -412,13 +436,17 @@ struct TableStructureView: View { ToolbarItem(placement: .confirmationAction) { if tournament.state() == .initial { ButtonValidateView { - _save(rebuildEverything: true) + Task { + await _save(rebuildEverything: true) + } } } else { let requirements = Set(updatedElements.compactMap { $0.requiresRebuilding }) ButtonValidateView(role: .destructive) { if requirements.isEmpty { - _save(rebuildEverything: false) + Task { + await _save(rebuildEverything: false) + } } else { presentRefreshStructureWarning = true } @@ -429,11 +457,15 @@ struct TableStructureView: View { } Button("Reconstruire les poules") { - _save(rebuildEverything: false) + Task { + await _save(rebuildEverything: false) + } } Button("Tout refaire", role: .destructive) { - _save(rebuildEverything: true) + Task { + await _save(rebuildEverything: true) + } } }, message: { ForEach(Array(requirements)) { requirement in @@ -511,7 +543,7 @@ struct TableStructureView: View { } } - private func _save(rebuildEverything: Bool = false) { + private func _save(rebuildEverything: Bool = false) async { _verifyValueIntegrity() do { @@ -531,8 +563,8 @@ struct TableStructureView: View { tournament.initialSeedRound = selectedTournament.initialSeedRound tournament.initialSeedCount = selectedTournament.initialSeedCount } else { - tournament.initialSeedRound = initialSeedRound - tournament.initialSeedCount = initialSeedCount + tournament.initialSeedRound = seedRepartition.firstIndex(where: { $0 > 0 }) ?? 0 + tournament.initialSeedCount = seedRepartition.first(where: { $0 > 0 }) ?? 0 } tournament.removeWildCards() if structurePreset.hasWildcards(), buildWildcards { @@ -541,6 +573,12 @@ struct TableStructureView: View { } tournament.deleteAndBuildEverything(preset: structurePreset) + if seedRepartition.count > 0 { + while tournament.rounds().count < seedRepartition.count { + await tournament.addNewRound(tournament.rounds().count) + } + } + if let selectedTournament { let oldTournamentStart = selectedTournament.startDate let newTournamentStart = tournament.startDate @@ -577,25 +615,65 @@ struct TableStructureView: View { tournament.tournamentStore?.matches.addOrUpdate(contentOfs: tournament._allMatchesIncludingDisabled()) } else { - if initialSeedRound > 0 { - if let round = tournament.rounds().first(where: { $0.index == initialSeedRound }) { - let seedSorted = frenchUmpireOrder(for: RoundRule.numberOfMatches(forRoundIndex: round.index)) - print(seedSorted) - seedSorted.prefix(initialSeedCount).forEach { index in - if let match = round._matches()[safe:index] { - if match.indexInRound() < RoundRule.numberOfMatches(forRoundIndex: round.index) / 2 { - match.previousMatch(.one)?.disableMatch() + for (index, seedCount) in seedRepartition.enumerated() { + if let round = tournament.rounds().first(where: { $0.index == index }) { + let baseIndex = RoundRule.baseIndex(forRoundIndex: round.index) + let numberOfMatches = RoundRule.numberOfMatches(forRoundIndex: round.index) + let playedMatches = round.playedMatches().map { $0.index - baseIndex } + let allMatches = round._matches() + let seedSorted = frenchUmpireOrder(for: numberOfMatches).filter({ index in + playedMatches.contains(index) + }).prefix(seedCount) + for (index, value) in seedSorted.enumerated() { + let isOpponentTurn = index >= playedMatches.count + if let match = allMatches[safe:value] { + if match.index - baseIndex < numberOfMatches / 2 { + if isOpponentTurn { + match.previousMatch(.two)?.disableMatch() + } else { + match.previousMatch(.one)?.disableMatch() + } } else { - match.previousMatch(.two)?.disableMatch() + if isOpponentTurn { + match.previousMatch(.one)?.disableMatch() + } else { + match.previousMatch(.two)?.disableMatch() + } } } } - if initialSeedCount > 0 { - tournament.tournamentStore?.matches.addOrUpdate(contentOfs: tournament._allMatchesIncludingDisabled()) + if seedCount > 0 { + tournament.tournamentStore?.matches.addOrUpdate(contentOfs: round._matches()) + tournament.tournamentStore?.matches.addOrUpdate(contentOfs: round.allLoserRoundMatches()) + round.deleteLoserBracket() + round.buildLoserBracket() + round.loserRounds().forEach { loserRound in + loserRound.disableUnplayedLoserBracketMatches() + } } } + } +// if initialSeedRound > 0 { +// if let round = tournament.rounds().first(where: { $0.index == initialSeedRound }) { +// let seedSorted = frenchUmpireOrder(for: RoundRule.numberOfMatches(forRoundIndex: round.index)) +// print(seedSorted) +// seedSorted.prefix(initialSeedCount).forEach { index in +// if let match = round._matches()[safe:index] { +// if match.indexInRound() < RoundRule.numberOfMatches(forRoundIndex: round.index) / 2 { +// match.previousMatch(.one)?.disableMatch() +// } else { +// match.previousMatch(.two)?.disableMatch() +// } +// } +// } +// +// if initialSeedCount > 0 { +// tournament.tournamentStore?.matches.addOrUpdate(contentOfs: tournament._allMatchesIncludingDisabled()) +// } +// } +// } } } else if (rebuildEverything == false && requirements.contains(.groupStage)) { tournament.deleteGroupStages() @@ -730,41 +808,44 @@ extension TableStructureView { // .environmentObject(DataStore.shared) // } //} - func frenchUmpireOrder(for matches: [Int]) -> [Int] { - guard matches.count > 1 else { return matches } - - // Base case - if matches.count == 2 { - return [matches[1], matches[0]] // bottom, top + if matches.count <= 1 { return matches } + if matches.count == 2 { return [matches[1], matches[0]] } + + var result: [Int] = [] + + // Step 1: Take last, then first + result.append(matches.last!) + result.append(matches.first!) + + // Step 2: Get remainder (everything except first and last) + let remainder = Array(matches[1.. [Int] { - return frenchUmpireOrder(for: Array(0..