add heads config system

main
Razmig Sarkissian 4 weeks ago
parent ac18a14863
commit 13011e2b1c
  1. 6
      PadelClub/Views/Round/RoundView.swift
  2. 182
      PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift
  3. 203
      PadelClub/Views/Tournament/Screen/TableStructureView.swift

@ -80,9 +80,11 @@ struct RoundView: View {
} }
} }
if let disabledMatchesCount, disabledMatchesCount > 0 { if let disabledMatchesCount {
if disabledMatchesCount > 0 {
let bracketTip = BracketEditTip(nextRoundName: upperRound.round.nextRound()?.roundTitle()) let bracketTip = BracketEditTip(nextRoundName: upperRound.round.nextRound()?.roundTitle())
TipView(bracketTip).tipStyle(tint: .green, asSection: true) TipView(bracketTip).tipStyle(tint: .green, asSection: true)
}
let leftToPlay = (RoundRule.numberOfMatches(forRoundIndex: upperRound.round.index) - disabledMatchesCount) let leftToPlay = (RoundRule.numberOfMatches(forRoundIndex: upperRound.round.index) - disabledMatchesCount)
@ -94,10 +96,12 @@ struct RoundView: View {
Text("Match\(leftToPlay.pluralSuffix) à jouer en \(upperRound.title)") Text("Match\(leftToPlay.pluralSuffix) à jouer en \(upperRound.title)")
} }
} footer: { } footer: {
if disabledMatchesCount > 0 {
Text("\(disabledMatchesCount) match\(disabledMatchesCount.pluralSuffix) désactivé\(disabledMatchesCount.pluralSuffix) automatiquement") Text("\(disabledMatchesCount) match\(disabledMatchesCount.pluralSuffix) désactivé\(disabledMatchesCount.pluralSuffix) automatiquement")
} }
} }
} }
}
if isEditingTournamentSeed.wrappedValue == false { if isEditingTournamentSeed.wrappedValue == false {
//(where: { $0.isDisabled() == false || isEditingTournamentSeed.wrappedValue }) //(where: { $0.isDisabled() == false || isEditingTournamentSeed.wrappedValue })

@ -11,33 +11,195 @@ import PadelClubData
struct HeadManagerView: View { struct HeadManagerView: View {
@Environment(Tournament.self) private var tournament: Tournament
@EnvironmentObject private var dataStore: DataStore @EnvironmentObject private var dataStore: DataStore
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@Binding var initialSeedRound: Int let teamsInBracket: Int
@Binding var initialSeedCount: 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 { var body: some View {
List { List {
Section { Section {
Picker(selection: $initialSeedRound) { Picker(selection: $selectedSeedRound) {
ForEach((0...10)) { Text("Choisir").tag(nil as Int?)
Text(RoundRule.roundName(fromRoundIndex: $0)).tag($0) ForEach(seedRepartition.indices, id: \.self) { idx in
Text(RoundRule.roundName(fromRoundIndex: idx)).tag(idx)
} }
} label: { } label: {
Text("Tour contenant la meilleure tête de série") Text("Tour contenant la meilleure tête de série")
} }
.onChange(of: initialSeedRound) { .onChange(of: selectedSeedRound) {
initialSeedCount = RoundRule.numberOfMatches(forRoundIndex: initialSeedRound) 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 { LabeledContent {
StepperView(count: $initialSeedCount, minimum: 0, maximum: RoundRule.numberOfMatches(forRoundIndex: initialSeedRound)) Text(leftToPlace.formatted())
} label: { } 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") .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..<minCount).first(where: { old[$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))")
}
} }
} }

@ -27,6 +27,8 @@ struct TableStructureView: View {
@State private var selectedTournament: Tournament? @State private var selectedTournament: Tournament?
@State private var initialSeedCount: Int = 0 @State private var initialSeedCount: Int = 0
@State private var initialSeedRound: Int = 0 @State private var initialSeedRound: Int = 0
@State private var showSeedRepartition: Bool = false
@State private var seedRepartition: [Int] = []
func displayWarning() -> Bool { func displayWarning() -> Bool {
let unsortedTeamsCount = tournament.unsortedTeamsCount() let unsortedTeamsCount = tournament.unsortedTeamsCount()
@ -64,6 +66,10 @@ struct TableStructureView: View {
max(teamCount - groupStageCount * teamsPerGroupStage, 0) max(teamCount - groupStageCount * teamsPerGroupStage, 0)
} }
var tf: Int {
max(teamCount - teamsFromGroupStages + qualifiedFromGroupStage + (groupStageCount > 0 ? groupStageAdditionalQualified : 0), 0)
}
init(tournament: Tournament) { init(tournament: Tournament) {
self.tournament = tournament self.tournament = tournament
_teamCount = .init(wrappedValue: tournament.teamCount) _teamCount = .init(wrappedValue: tournament.teamCount)
@ -92,7 +98,14 @@ struct TableStructureView: View {
Text("Préréglage") Text("Préréglage")
} }
.disabled(selectedTournament != nil) .disabled(selectedTournament != nil)
} footer: {
Text(structurePreset.localizedDescriptionStructurePresetTitle())
}
.onChange(of: structurePreset) {
_updatePreset()
}
Section {
NavigationLink { NavigationLink {
TournamentSelectorView(selectedTournament: $selectedTournament) TournamentSelectorView(selectedTournament: $selectedTournament)
.environment(tournament) .environment(tournament)
@ -103,24 +116,10 @@ struct TableStructureView: View {
Text("À partir d'un tournoi existant") 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) { .onChange(of: selectedTournament) {
_updatePreset() _updatePreset()
} }
.onChange(of: structurePreset) {
_updatePreset()
}
} }
Section { Section {
@ -240,7 +239,6 @@ struct TableStructureView: View {
} }
Section { Section {
let tf = max(teamCount - teamsFromGroupStages + qualifiedFromGroupStage + (groupStageCount > 0 ? groupStageAdditionalQualified : 0), 0)
if groupStageCount > 0 { if groupStageCount > 0 {
if structurePreset != .doubleGroupStage { if structurePreset != .doubleGroupStage {
LabeledContent { LabeledContent {
@ -268,15 +266,13 @@ struct TableStructureView: View {
Text("Attention !").foregroundStyle(.red) Text("Attention !").foregroundStyle(.red)
} }
} }
if groupStageCount > 0 {
LabeledContent { LabeledContent {
Text(tf.formatted()) Text(tf.formatted())
} label: { } label: {
Text("Effectif") Text("Effectif")
} }
LabeledContent {
Text(RoundRule.teamsInFirstRound(forTeams: tf).formatted())
} label: {
Text("Dimension")
} }
} else { } else {
LabeledContent { 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 { if tournament.rounds().isEmpty && tournament.state() == .build {
Section { Section {
RowButtonView("Ajouter un tableau", role: .destructive) { RowButtonView("Ajouter un tableau", role: .destructive) {
@ -327,13 +344,13 @@ struct TableStructureView: View {
Section { Section {
RowButtonView("Reconstruire les poules", role:.destructive) { RowButtonView("Reconstruire les poules", role:.destructive) {
_save(rebuildEverything: false) await _save(rebuildEverything: false)
} }
} }
Section { Section {
RowButtonView("Tout refaire", role: .destructive) { RowButtonView("Tout refaire", role: .destructive) {
_save(rebuildEverything: true) await _save(rebuildEverything: true)
} }
} }
@ -360,6 +377,13 @@ struct TableStructureView: View {
updatedElements.remove(.teamCount) updatedElements.remove(.teamCount)
} }
} }
.sheet(isPresented: $showSeedRepartition, content: {
NavigationStack {
HeadManagerView(teamsInBracket: tf, heads: tsPure, initialSeedRepartition: seedRepartition) { seedRepartition in
self.seedRepartition = seedRepartition
}
}
})
.onChange(of: groupStageCount) { .onChange(of: groupStageCount) {
if groupStageCount != tournament.groupStageCount { if groupStageCount != tournament.groupStageCount {
updatedElements.insert(.groupStageCount) updatedElements.insert(.groupStageCount)
@ -412,13 +436,17 @@ struct TableStructureView: View {
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
if tournament.state() == .initial { if tournament.state() == .initial {
ButtonValidateView { ButtonValidateView {
_save(rebuildEverything: true) Task {
await _save(rebuildEverything: true)
}
} }
} else { } else {
let requirements = Set(updatedElements.compactMap { $0.requiresRebuilding }) let requirements = Set(updatedElements.compactMap { $0.requiresRebuilding })
ButtonValidateView(role: .destructive) { ButtonValidateView(role: .destructive) {
if requirements.isEmpty { if requirements.isEmpty {
_save(rebuildEverything: false) Task {
await _save(rebuildEverything: false)
}
} else { } else {
presentRefreshStructureWarning = true presentRefreshStructureWarning = true
} }
@ -429,11 +457,15 @@ struct TableStructureView: View {
} }
Button("Reconstruire les poules") { Button("Reconstruire les poules") {
_save(rebuildEverything: false) Task {
await _save(rebuildEverything: false)
}
} }
Button("Tout refaire", role: .destructive) { Button("Tout refaire", role: .destructive) {
_save(rebuildEverything: true) Task {
await _save(rebuildEverything: true)
}
} }
}, message: { }, message: {
ForEach(Array(requirements)) { requirement in 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() _verifyValueIntegrity()
do { do {
@ -531,8 +563,8 @@ struct TableStructureView: View {
tournament.initialSeedRound = selectedTournament.initialSeedRound tournament.initialSeedRound = selectedTournament.initialSeedRound
tournament.initialSeedCount = selectedTournament.initialSeedCount tournament.initialSeedCount = selectedTournament.initialSeedCount
} else { } else {
tournament.initialSeedRound = initialSeedRound tournament.initialSeedRound = seedRepartition.firstIndex(where: { $0 > 0 }) ?? 0
tournament.initialSeedCount = initialSeedCount tournament.initialSeedCount = seedRepartition.first(where: { $0 > 0 }) ?? 0
} }
tournament.removeWildCards() tournament.removeWildCards()
if structurePreset.hasWildcards(), buildWildcards { if structurePreset.hasWildcards(), buildWildcards {
@ -541,6 +573,12 @@ struct TableStructureView: View {
} }
tournament.deleteAndBuildEverything(preset: structurePreset) tournament.deleteAndBuildEverything(preset: structurePreset)
if seedRepartition.count > 0 {
while tournament.rounds().count < seedRepartition.count {
await tournament.addNewRound(tournament.rounds().count)
}
}
if let selectedTournament { if let selectedTournament {
let oldTournamentStart = selectedTournament.startDate let oldTournamentStart = selectedTournament.startDate
let newTournamentStart = tournament.startDate let newTournamentStart = tournament.startDate
@ -577,25 +615,65 @@ struct TableStructureView: View {
tournament.tournamentStore?.matches.addOrUpdate(contentOfs: tournament._allMatchesIncludingDisabled()) tournament.tournamentStore?.matches.addOrUpdate(contentOfs: tournament._allMatchesIncludingDisabled())
} else { } else {
if initialSeedRound > 0 { for (index, seedCount) in seedRepartition.enumerated() {
if let round = tournament.rounds().first(where: { $0.index == initialSeedRound }) { if let round = tournament.rounds().first(where: { $0.index == index }) {
let seedSorted = frenchUmpireOrder(for: RoundRule.numberOfMatches(forRoundIndex: round.index)) let baseIndex = RoundRule.baseIndex(forRoundIndex: round.index)
print(seedSorted) let numberOfMatches = RoundRule.numberOfMatches(forRoundIndex: round.index)
seedSorted.prefix(initialSeedCount).forEach { index in let playedMatches = round.playedMatches().map { $0.index - baseIndex }
if let match = round._matches()[safe:index] { let allMatches = round._matches()
if match.indexInRound() < RoundRule.numberOfMatches(forRoundIndex: round.index) / 2 { 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 {
if isOpponentTurn {
match.previousMatch(.one)?.disableMatch() match.previousMatch(.one)?.disableMatch()
} else { } else {
match.previousMatch(.two)?.disableMatch() match.previousMatch(.two)?.disableMatch()
} }
} }
} }
}
if initialSeedCount > 0 { if seedCount > 0 {
tournament.tournamentStore?.matches.addOrUpdate(contentOfs: tournament._allMatchesIncludingDisabled()) 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)) { } else if (rebuildEverything == false && requirements.contains(.groupStage)) {
tournament.deleteGroupStages() tournament.deleteGroupStages()
@ -730,41 +808,44 @@ extension TableStructureView {
// .environmentObject(DataStore.shared) // .environmentObject(DataStore.shared)
// } // }
//} //}
func frenchUmpireOrder(for matches: [Int]) -> [Int] { func frenchUmpireOrder(for matches: [Int]) -> [Int] {
guard matches.count > 1 else { return matches } if matches.count <= 1 { return matches }
if matches.count == 2 { return [matches[1], matches[0]] }
// Base case var result: [Int] = []
if matches.count == 2 {
return [matches[1], matches[0]] // bottom, top
}
let n = matches.count
let mid = n / 2
let topHalf = Array(matches[0..<mid]) // Step 1: Take last, then first
let bottomHalf = Array(matches[mid..<n]) result.append(matches.last!)
result.append(matches.first!)
var order: [Int] = [] // Step 2: Get remainder (everything except first and last)
let remainder = Array(matches[1..<matches.count-1])
// Step 1: last match of round (bottom) if remainder.isEmpty { return result }
order.append(bottomHalf.last!)
// Step 2: first match of round (top) // Step 3: Split remainder in half
order.append(topHalf.first!) let mid = remainder.count / 2
let firstHalf = Array(remainder[0..<mid])
let secondHalf = Array(remainder[mid..<remainder.count])
// Step 3 & 4: recursively apply on halves minus the ones we just used // Step 4: Take first of 2nd half, then last of 1st half
let topInner = Array(topHalf.dropFirst()) if !secondHalf.isEmpty {
let bottomInner = Array(bottomHalf.dropLast()) result.append(secondHalf.first!)
}
if !firstHalf.isEmpty {
result.append(firstHalf.last!)
}
let innerOrder = frenchUmpireOrder(for: bottomInner) + frenchUmpireOrder(for: topInner) // Step 5: Build new remainder from what's left and recurse
order.append(contentsOf: innerOrder) let newRemainder = Array(firstHalf.dropLast()) + Array(secondHalf.dropFirst())
result.append(contentsOf: frenchUmpireOrder(for: newRemainder))
return order return result
} }
/// Convenience wrapper to call by matchCount /// Convenience wrapper
func frenchUmpireOrder(for matchCount: Int) -> [Int] { func frenchUmpireOrder(for matchCount: Int) -> [Int] {
return frenchUmpireOrder(for: Array(0..<matchCount)) let m = frenchUmpireOrder(for: Array(0..<matchCount))
return m + m.reversed()
} }

Loading…
Cancel
Save