add heads config system

main
Razmig Sarkissian 4 weeks ago
parent ac18a14863
commit 13011e2b1c
  1. 12
      PadelClub/Views/Round/RoundView.swift
  2. 186
      PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift
  3. 233
      PadelClub/Views/Tournament/Screen/TableStructureView.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")
}
}
}
}

@ -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..<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,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..<matches.count-1])
if remainder.isEmpty { return result }
// Step 3: Split remainder in half
let mid = remainder.count / 2
let firstHalf = Array(remainder[0..<mid])
let secondHalf = Array(remainder[mid..<remainder.count])
// Step 4: Take first of 2nd half, then last of 1st half
if !secondHalf.isEmpty {
result.append(secondHalf.first!)
}
let n = matches.count
let mid = n / 2
let topHalf = Array(matches[0..<mid])
let bottomHalf = Array(matches[mid..<n])
var order: [Int] = []
// Step 1: last match of round (bottom)
order.append(bottomHalf.last!)
// Step 2: first match of round (top)
order.append(topHalf.first!)
// Step 3 & 4: recursively apply on halves minus the ones we just used
let topInner = Array(topHalf.dropFirst())
let bottomInner = Array(bottomHalf.dropLast())
let innerOrder = frenchUmpireOrder(for: bottomInner) + frenchUmpireOrder(for: topInner)
order.append(contentsOf: innerOrder)
return order
if !firstHalf.isEmpty {
result.append(firstHalf.last!)
}
// Step 5: Build new remainder from what's left and recurse
let newRemainder = Array(firstHalf.dropLast()) + Array(secondHalf.dropFirst())
result.append(contentsOf: frenchUmpireOrder(for: newRemainder))
return result
}
/// Convenience wrapper to call by matchCount
/// Convenience wrapper
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