You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1030 lines
40 KiB
1030 lines
40 KiB
//
|
|
// Round.swift
|
|
// Padel Tournament
|
|
//
|
|
// Created by razmig on 10/03/2024.
|
|
//
|
|
|
|
import Foundation
|
|
import LeStorage
|
|
import SwiftUI
|
|
|
|
@Observable
|
|
final public class Round: BaseRound, SideStorable {
|
|
|
|
private var _cachedSeedInterval: SeedInterval?
|
|
private var _cachedLoserRounds: [Round]?
|
|
private var _cachedLoserRoundsAndChildren: [Round]?
|
|
|
|
public init(tournament: String, index: Int, parent: String? = nil, matchFormat: MatchFormat? = nil, startDate: Date? = nil, groupStageLoserBracket: Bool = false, loserBracketMode: LoserBracketMode = .automatic) {
|
|
|
|
super.init(tournament: tournament, index: index, parent: parent, format: matchFormat, startDate: startDate, groupStageLoserBracket: groupStageLoserBracket, loserBracketMode: loserBracketMode)
|
|
|
|
// self.lastUpdate = Date()
|
|
// self.tournament = tournament
|
|
// self.index = index
|
|
// self.parent = parent
|
|
// self.format = matchFormat
|
|
// self.startDate = startDate
|
|
// self.groupStageLoserBracket = groupStageLoserBracket
|
|
// self.loserBracketMode = loserBracketMode
|
|
}
|
|
|
|
required init(from decoder: any Decoder) throws {
|
|
try super.init(from: decoder)
|
|
}
|
|
|
|
required public init() {
|
|
super.init()
|
|
}
|
|
|
|
// MARK: - Computed dependencies
|
|
|
|
public var tournamentStore: TournamentStore? {
|
|
return TournamentLibrary.shared.store(tournamentId: self.tournament)
|
|
}
|
|
|
|
public func tournamentObject() -> Tournament? {
|
|
return Store.main.findById(tournament)
|
|
}
|
|
|
|
public func _unsortedMatches(includeDisabled: Bool) -> [Match] {
|
|
guard let tournamentStore = self.tournamentStore else { return [] }
|
|
if includeDisabled {
|
|
return tournamentStore.matches.filter { $0.round == self.id }
|
|
} else {
|
|
return tournamentStore.matches.filter { $0.round == self.id && $0.disabled == false }
|
|
}
|
|
}
|
|
|
|
public func _matches() -> [Match] {
|
|
guard let tournamentStore = self.tournamentStore else { return [] }
|
|
return tournamentStore.matches.filter { $0.round == self.id }.sorted(by: \.index)
|
|
}
|
|
|
|
public func getDisabledMatches() -> [Match] {
|
|
guard let tournamentStore = self.tournamentStore else { return [] }
|
|
return tournamentStore.matches.filter { $0.round == self.id && $0.disabled == true }
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public var matchFormat: MatchFormat {
|
|
get {
|
|
format ?? .defaultFormatForMatchType(.bracket)
|
|
}
|
|
set {
|
|
format = newValue
|
|
}
|
|
}
|
|
|
|
public func hasStarted() -> Bool {
|
|
return playedMatches().anySatisfy({ $0.hasStarted() })
|
|
}
|
|
|
|
public func hasEnded() -> Bool {
|
|
if isUpperBracket() {
|
|
return playedMatches().anySatisfy({ $0.hasEnded() == false }) == false
|
|
} else {
|
|
return enabledMatches().anySatisfy({ $0.hasEnded() == false }) == false
|
|
}
|
|
}
|
|
|
|
public func upperMatches(upperRound: Round, match: Match) -> [Match] {
|
|
let matchIndex = match.index
|
|
let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex)
|
|
return [upperRound.getMatch(atMatchIndexInRound: indexInRound * 2), upperRound.getMatch(atMatchIndexInRound: indexInRound * 2 + 1)].compactMap({ $0 })
|
|
}
|
|
|
|
public func previousMatches(previousRound: Round, match: Match) -> [Match] {
|
|
guard let tournamentStore = self.tournamentStore else { return [] }
|
|
return tournamentStore.matches.filter {
|
|
$0.round == previousRound.id && ($0.index == match.topPreviousRoundMatchIndex() || $0.index == match.bottomPreviousRoundMatchIndex())
|
|
}
|
|
}
|
|
|
|
public func upperMatches(ofMatch match: Match) -> [Match] {
|
|
if parent != nil, previousRound() == nil, let parentRound {
|
|
let matchIndex = match.index
|
|
let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex)
|
|
return [parentRound.getMatch(atMatchIndexInRound: indexInRound * 2), parentRound.getMatch(atMatchIndexInRound: indexInRound * 2 + 1)].compactMap({ $0 })
|
|
}
|
|
return []
|
|
}
|
|
|
|
public func previousMatches(ofMatch match: Match) -> [Match] {
|
|
guard let previousRound = previousRound() else { return [] }
|
|
guard let tournamentStore = self.tournamentStore else { return [] }
|
|
|
|
return tournamentStore.matches.filter {
|
|
$0.round == previousRound.id && ($0.index == match.topPreviousRoundMatchIndex() || $0.index == match.bottomPreviousRoundMatchIndex())
|
|
}
|
|
|
|
// return Store.main.filter {
|
|
// ($0.index == match.topPreviousRoundMatchIndex() || $0.index == match.bottomPreviousRoundMatchIndex()) && $0.round == previousRound.id
|
|
// }
|
|
}
|
|
|
|
public func precedentMatches(ofMatch match: Match) -> [Match] {
|
|
let upper = upperMatches(ofMatch: match)
|
|
if upper.isEmpty == false {
|
|
return upper
|
|
}
|
|
let previous : [Match] = previousMatches(ofMatch: match)
|
|
if previous.isEmpty == false && previous.allSatisfy({ $0.disabled }), let previousRound = previousRound() {
|
|
return previous.flatMap({ previousRound.precedentMatches(ofMatch: $0) })
|
|
} else {
|
|
return previous
|
|
}
|
|
}
|
|
|
|
public func team(_ team: TeamPosition, inMatch match: Match, previousRound: Round?) -> TeamRegistration? {
|
|
return roundProjectedTeam(team, inMatch: match, previousRound: previousRound)
|
|
}
|
|
|
|
public func seed(_ team: TeamPosition, inMatchIndex matchIndex: Int) -> TeamRegistration? {
|
|
return self.tournamentStore?.teamRegistrations.first(where: {
|
|
$0.bracketPosition != nil
|
|
&& ($0.bracketPosition! / 2) == matchIndex
|
|
&& ($0.bracketPosition! % 2) == team.rawValue
|
|
})
|
|
}
|
|
|
|
public func seeds(inMatchIndex matchIndex: Int) -> [TeamRegistration] {
|
|
guard let tournamentStore = self.tournamentStore else { return [] }
|
|
return tournamentStore.teamRegistrations.filter {
|
|
|
|
$0.tournament == tournament
|
|
&& $0.bracketPosition != nil
|
|
&& ($0.bracketPosition! / 2) == matchIndex
|
|
}
|
|
|
|
// return Store.main.filter(isIncluded: {
|
|
// $0.tournament == tournament
|
|
// && $0.bracketPosition != nil
|
|
// && ($0.bracketPosition! / 2) == matchIndex
|
|
// })
|
|
}
|
|
|
|
public func seeds() -> [TeamRegistration] {
|
|
guard let tournamentStore = self.tournamentStore else { return [] }
|
|
let initialMatchIndex = RoundRule.matchIndex(fromRoundIndex: index)
|
|
let numberOfMatches = RoundRule.numberOfMatches(forRoundIndex: index)
|
|
return tournamentStore.teamRegistrations.filter {
|
|
$0.bracketPosition != nil
|
|
&& ($0.bracketPosition! / 2) >= initialMatchIndex
|
|
&& ($0.bracketPosition! / 2) < initialMatchIndex + numberOfMatches
|
|
}
|
|
}
|
|
|
|
public func teamsOrSeeds() -> [TeamRegistration] {
|
|
let seeds = seeds()
|
|
if seeds.isEmpty {
|
|
return playedMatches().flatMap({ $0.teams() })
|
|
} else {
|
|
return seeds
|
|
}
|
|
}
|
|
|
|
|
|
public func losers() -> [TeamRegistration] {
|
|
let teamIds: [String] = self._unsortedMatches(includeDisabled: false).compactMap { $0.losingTeamId }
|
|
return teamIds.compactMap { self.tournamentStore?.teamRegistrations.findById($0) }
|
|
}
|
|
|
|
public func winners() -> [TeamRegistration] {
|
|
let teamIds: [String] = self._unsortedMatches(includeDisabled: false).compactMap { $0.winningTeamId }
|
|
return teamIds.compactMap { self.tournamentStore?.teamRegistrations.findById($0) }
|
|
}
|
|
|
|
public func teams() -> [TeamRegistration] {
|
|
return playedMatches().flatMap({ $0.teams() })
|
|
}
|
|
|
|
public func roundProjectedTeam(_ team: TeamPosition, inMatch match: Match, previousRound: Round?) -> TeamRegistration? {
|
|
#if _DEBUG_TIME //DEBUGING TIME
|
|
let start = Date()
|
|
defer {
|
|
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
|
|
print("func roundProjectedTeam", team.rawValue, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
|
|
}
|
|
#endif
|
|
if isLoserBracket() == false, let seed = seed(team, inMatchIndex: match.index) {
|
|
return seed
|
|
}
|
|
|
|
switch team {
|
|
case .one:
|
|
if let luckyLoser = match.teamScores.first(where: { $0.luckyLoser == match.index * 2 }) {
|
|
return luckyLoser.team
|
|
} else if let previousMatch = topPreviousRoundMatch(ofMatch: match, previousRound: previousRound) {
|
|
if let teamId = previousMatch.winningTeamId {
|
|
return self.tournamentStore?.teamRegistrations.findById(teamId)
|
|
} else if previousMatch.disabled {
|
|
return previousMatch.teams().first
|
|
}
|
|
} else if let parent = upperBracketTopMatch(ofMatchIndex: match.index, previousRound: previousRound)?.losingTeamId {
|
|
return self.store?.findById(parent)
|
|
}
|
|
case .two:
|
|
if let luckyLoser = match.teamScores.first(where: { $0.luckyLoser == match.index * 2 + 1 }) {
|
|
return luckyLoser.team
|
|
} else if let previousMatch = bottomPreviousRoundMatch(ofMatch: match, previousRound: previousRound) {
|
|
if let teamId = previousMatch.winningTeamId {
|
|
return self.tournamentStore?.teamRegistrations.findById(teamId)
|
|
} else if previousMatch.disabled {
|
|
return previousMatch.teams().first
|
|
}
|
|
} else if let parent = upperBracketBottomMatch(ofMatchIndex: match.index, previousRound: previousRound)?.losingTeamId {
|
|
return self.store?.findById(parent)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
public func upperBracketTopMatch(ofMatchIndex matchIndex: Int, previousRound: Round?) -> Match? {
|
|
#if _DEBUG_TIME //DEBUGING TIME
|
|
let start = Date()
|
|
defer {
|
|
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
|
|
print("func upperBracketTopMatch", matchIndex, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
|
|
}
|
|
#endif
|
|
let parentRound = parentRound
|
|
if let parentRound, parentRound.parent == nil, groupStageLoserBracket == false, parentRound.loserBracketMode != .automatic {
|
|
return nil
|
|
}
|
|
|
|
let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex)
|
|
if isLoserBracket(), previousRound == nil, let upperBracketTopMatch = parentRound?.getMatch(atMatchIndexInRound: indexInRound * 2) {
|
|
return upperBracketTopMatch
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public func upperBracketBottomMatch(ofMatchIndex matchIndex: Int, previousRound: Round?) -> Match? {
|
|
#if _DEBUG_TIME //DEBUGING TIME
|
|
let start = Date()
|
|
defer {
|
|
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
|
|
print("func upperBracketBottomMatch", matchIndex, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
|
|
}
|
|
#endif
|
|
|
|
let parentRound = parentRound
|
|
if let parentRound, parentRound.parent == nil, groupStageLoserBracket == false, parentRound.loserBracketMode != .automatic {
|
|
return nil
|
|
}
|
|
|
|
let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex)
|
|
if isLoserBracket(), previousRound == nil, let upperBracketBottomMatch = parentRound?.getMatch(atMatchIndexInRound: indexInRound * 2 + 1) {
|
|
return upperBracketBottomMatch
|
|
}
|
|
return nil
|
|
}
|
|
|
|
|
|
public func topPreviousRoundMatch(ofMatch match: Match, previousRound: Round?) -> Match? {
|
|
#if _DEBUG_TIME //DEBUGING TIME
|
|
let start = Date()
|
|
defer {
|
|
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
|
|
print("func topPreviousRoundMatch", match.id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
|
|
}
|
|
#endif
|
|
|
|
guard let previousRound else { return nil }
|
|
let topPreviousRoundMatchIndex = match.topPreviousRoundMatchIndex()
|
|
return self.tournamentStore?.matches.first(where: {
|
|
$0.round == previousRound.id && $0.index == topPreviousRoundMatchIndex
|
|
})
|
|
}
|
|
|
|
public func bottomPreviousRoundMatch(ofMatch match: Match, previousRound: Round?) -> Match? {
|
|
#if _DEBUG_TIME //DEBUGING TIME
|
|
let start = Date()
|
|
defer {
|
|
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
|
|
print("func bottomPreviousRoundMatch", match.id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
|
|
}
|
|
#endif
|
|
|
|
guard let previousRound else { return nil }
|
|
let bottomPreviousRoundMatchIndex = match.bottomPreviousRoundMatchIndex()
|
|
return self.tournamentStore?.matches.first(where: {
|
|
$0.round == previousRound.id && $0.index == bottomPreviousRoundMatchIndex
|
|
})
|
|
}
|
|
|
|
public func getMatch(atMatchIndexInRound matchIndexInRound: Int) -> Match? {
|
|
self.tournamentStore?.matches.first(where: {
|
|
let index = RoundRule.matchIndexWithinRound(fromMatchIndex: $0.index)
|
|
return $0.round == id && index == matchIndexInRound
|
|
})
|
|
}
|
|
|
|
public func enabledMatches() -> [Match] {
|
|
guard let tournamentStore = self.tournamentStore else { return [] }
|
|
return tournamentStore.matches.filter { $0.disabled == false && $0.round == self.id }.sorted(by: \.index)
|
|
}
|
|
|
|
// public func displayableMatches() -> [Match] {
|
|
//#if _DEBUG_TIME //DEBUGING TIME
|
|
// let start = Date()
|
|
// defer {
|
|
// let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
|
|
// print("func displayableMatches of round: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
|
|
// }
|
|
//#endif
|
|
//
|
|
// if index == 0 && isUpperBracket() {
|
|
// var matches : [Match?] = [playedMatches().first]
|
|
// matches.append(loserRounds().first?.playedMatches().first)
|
|
// return matches.compactMap({ $0 })
|
|
// } else {
|
|
// return playedMatches()
|
|
// }
|
|
// }
|
|
|
|
public func playedMatches() -> [Match] {
|
|
if isUpperBracket() {
|
|
return enabledMatches()
|
|
} else {
|
|
return _matches()
|
|
}
|
|
}
|
|
|
|
public func previousRound() -> Round? {
|
|
#if _DEBUG_TIME //DEBUGING TIME
|
|
let start = Date()
|
|
defer {
|
|
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
|
|
print("func previousRound of: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
|
|
}
|
|
#endif
|
|
return self.tournamentStore?.rounds.first(where: { $0.parent == parent && $0.index == index + 1 })
|
|
}
|
|
|
|
public func nextRound() -> Round? {
|
|
return self.tournamentStore?.rounds.first(where: { $0.parent == parent && $0.index == index - 1 })
|
|
}
|
|
|
|
public func loserRounds(forRoundIndex roundIndex: Int) -> [Round] {
|
|
return loserRoundsAndChildren().filter({ $0.index == roundIndex }).sorted(by: \.theoryCumulativeMatchCount)
|
|
}
|
|
|
|
public func loserRounds(forRoundIndex roundIndex: Int, loserRoundsAndChildren: [Round]) -> [Round] {
|
|
return loserRoundsAndChildren.filter({ $0.index == roundIndex }).sorted(by: \.theoryCumulativeMatchCount)
|
|
}
|
|
|
|
public func isEnabled() -> Bool {
|
|
return _unsortedMatches(includeDisabled: false).isEmpty == false
|
|
}
|
|
|
|
public func isDisabled() -> Bool {
|
|
return _unsortedMatches(includeDisabled: true).allSatisfy({ $0.disabled })
|
|
}
|
|
|
|
public func isRankDisabled() -> Bool {
|
|
return _unsortedMatches(includeDisabled: true).allSatisfy({ $0.disabled && $0.teamScores.isEmpty })
|
|
}
|
|
|
|
public func resetFromRoundAllMatchesStartDate() {
|
|
_unsortedMatches(includeDisabled: false).forEach({
|
|
$0.startDate = nil
|
|
})
|
|
loserRoundsAndChildren().forEach { round in
|
|
round.resetFromRoundAllMatchesStartDate()
|
|
}
|
|
nextRound()?.resetFromRoundAllMatchesStartDate()
|
|
}
|
|
|
|
public func resetFromRoundAllMatchesStartDate(from match: Match) {
|
|
let matches = _unsortedMatches(includeDisabled: false)
|
|
if let index = matches.firstIndex(where: { $0.id == match.id }) {
|
|
matches[index...].forEach { match in
|
|
match.startDate = nil
|
|
}
|
|
}
|
|
loserRoundsAndChildren().forEach { round in
|
|
round.resetFromRoundAllMatchesStartDate()
|
|
}
|
|
nextRound()?.resetFromRoundAllMatchesStartDate()
|
|
}
|
|
|
|
public func getActiveLoserRound() -> Round? {
|
|
// Get all loser rounds once
|
|
let allLoserRounds = loserRounds()
|
|
var lowestIndexRound: Round? = nil
|
|
let currentRoundMatchCount = RoundRule.numberOfMatches(forRoundIndex: index)
|
|
let roundCount = RoundRule.numberOfRounds(forTeams: currentRoundMatchCount)
|
|
|
|
for currentIndex in 0..<roundCount { // Assuming no tournament has >100 rounds
|
|
// Find non-disabled round with current index
|
|
let roundAtIndex = allLoserRounds.first(where: { $0.index == currentIndex && $0.isEnabled() })
|
|
|
|
// No round at this index, we've checked all available rounds
|
|
if roundAtIndex == nil {
|
|
break
|
|
}
|
|
|
|
// Save the first non-disabled round we find (should be index 0)
|
|
if lowestIndexRound == nil {
|
|
lowestIndexRound = roundAtIndex
|
|
}
|
|
|
|
// If this round is active, return it immediately
|
|
if roundAtIndex!.hasStarted() && !roundAtIndex!.hasEnded() {
|
|
return roundAtIndex
|
|
}
|
|
}
|
|
|
|
// If no active round found, return the one with lowest index
|
|
return lowestIndexRound
|
|
}
|
|
|
|
public func enableRound() {
|
|
_toggleRound(disable: false)
|
|
}
|
|
|
|
public func disableRound() {
|
|
_toggleRound(disable: true)
|
|
}
|
|
|
|
private func _toggleRound(disable: Bool) {
|
|
let _matches = _unsortedMatches(includeDisabled: true)
|
|
_matches.forEach { match in
|
|
match.disabled = disable
|
|
match.resetMatch()
|
|
//we need to keep teamscores to handle disable ranking match round stuff
|
|
// do {
|
|
// try DataStore.shared.teamScores.delete(contentOfs: match.teamScores)
|
|
// } catch {
|
|
// Logger.error(error)
|
|
// }
|
|
}
|
|
self.tournamentStore?.matches.addOrUpdate(contentOfs: _matches)
|
|
}
|
|
|
|
public var cumulativeMatchCount: Int {
|
|
var totalMatches = _unsortedMatches(includeDisabled: false).count
|
|
if let parentRound {
|
|
totalMatches += parentRound.cumulativeMatchCount
|
|
}
|
|
return totalMatches
|
|
}
|
|
|
|
public func initialRound() -> Round? {
|
|
if let parentRound {
|
|
return parentRound.initialRound()
|
|
} else {
|
|
return self
|
|
}
|
|
}
|
|
|
|
public func estimatedEndDate(_ additionalEstimationDuration: Int) -> Date? {
|
|
return enabledMatches().last?.estimatedEndDate(additionalEstimationDuration)
|
|
}
|
|
|
|
public func getLoserRoundStartDate() -> Date? {
|
|
return loserRoundsAndChildren().first(where: { $0.isDisabled() == false })?.enabledMatches().first?.startDate
|
|
}
|
|
|
|
public func estimatedLoserRoundEndDate(_ additionalEstimationDuration: Int) -> Date? {
|
|
let lastMatch = loserRoundsAndChildren().last(where: { $0.isDisabled() == false })?.enabledMatches().last
|
|
return lastMatch?.estimatedEndDate(additionalEstimationDuration)
|
|
}
|
|
|
|
public func disabledMatches() -> [Match] {
|
|
guard let tournamentStore = self.tournamentStore else { return [] }
|
|
return tournamentStore.matches.filter { $0.round == self.id && $0.disabled == true }
|
|
}
|
|
|
|
public func allLoserRoundMatches() -> [Match] {
|
|
loserRoundsAndChildren().flatMap({ $0._unsortedMatches(includeDisabled: false) })
|
|
}
|
|
|
|
public var theoryCumulativeMatchCount: Int {
|
|
var totalMatches = RoundRule.numberOfMatches(forRoundIndex: index)
|
|
if let parentRound {
|
|
totalMatches += parentRound.theoryCumulativeMatchCount
|
|
}
|
|
return totalMatches
|
|
}
|
|
|
|
|
|
public func correspondingLoserRoundTitle(_ displayStyle: DisplayStyle = .wide) -> String {
|
|
if let _cachedSeedInterval { return _cachedSeedInterval.localizedLabel(displayStyle) }
|
|
|
|
#if _DEBUG_TIME //DEBUGING TIME
|
|
let start = Date()
|
|
defer {
|
|
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
|
|
print("func correspondingLoserRoundTitle()", duration.formatted(.units(allowed: [.seconds, .milliseconds])))
|
|
}
|
|
#endif
|
|
let initialMatchIndexFromRoundIndex = RoundRule.matchIndex(fromRoundIndex: index)
|
|
var seedsAfterThisRound: [TeamRegistration] = []
|
|
if let tournamentStore = tournamentStore {
|
|
seedsAfterThisRound = tournamentStore.teamRegistrations.filter {
|
|
$0.bracketPosition != nil
|
|
&& ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex
|
|
}
|
|
}
|
|
|
|
// let seedsAfterThisRound : [TeamRegistration] = Store.main.filter(isIncluded: {
|
|
// $0.tournament == tournament
|
|
// && $0.bracketPosition != nil
|
|
// && ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex
|
|
// })
|
|
|
|
var seedsCount = seedsAfterThisRound.count
|
|
if seedsAfterThisRound.isEmpty {
|
|
let nextRoundsDisableMatches = nextRoundsDisableMatches()
|
|
seedsCount = disabledMatches().count - nextRoundsDisableMatches
|
|
}
|
|
let playedMatches = playedMatches()
|
|
let seedInterval = SeedInterval(first: playedMatches.count + seedsCount + 1, last: playedMatches.count * 2 + seedsCount)
|
|
|
|
_cachedSeedInterval = seedInterval
|
|
return seedInterval.localizedLabel(displayStyle)
|
|
}
|
|
|
|
public func hasNextRound() -> Bool {
|
|
return nextRound()?.isRankDisabled() == false
|
|
}
|
|
|
|
public func pasteData() -> String {
|
|
var data: [String] = []
|
|
data.append(self.roundTitle())
|
|
|
|
playedMatches().forEach { match in
|
|
data.append(match.matchTitle())
|
|
data.append(match.team(.one)?.teamLabelRanked(displayRank: true, displayTeamName: true) ?? "-----")
|
|
data.append(match.team(.two)?.teamLabelRanked(displayRank: true, displayTeamName: true) ?? "-----")
|
|
}
|
|
|
|
return data.joined(separator: "\n")
|
|
}
|
|
|
|
public func seedInterval(initialMode: Bool = false) -> SeedInterval? {
|
|
if initialMode == false, let _cachedSeedInterval { return _cachedSeedInterval }
|
|
|
|
#if _DEBUG_TIME //DEBUGING TIME
|
|
let start = Date()
|
|
defer {
|
|
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
|
|
print("func seedInterval(initialMode)", id, index, initialMode, _cachedSeedInterval, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
|
|
}
|
|
#endif
|
|
|
|
if isUpperBracket() {
|
|
if index == 0 { return SeedInterval(first: 1, last: 2) }
|
|
let initialMatchIndexFromRoundIndex = RoundRule.matchIndex(fromRoundIndex: index)
|
|
|
|
if initialMode {
|
|
let playedMatches = RoundRule.numberOfMatches(forRoundIndex: index)
|
|
let seedInterval = SeedInterval(first: playedMatches + 1, last: playedMatches * 2)
|
|
//print(seedInterval.localizedLabel())
|
|
return seedInterval
|
|
} else {
|
|
var seedsAfterThisRound : [TeamRegistration] = []
|
|
if let tournamentStore = self.tournamentStore {
|
|
seedsAfterThisRound = tournamentStore.teamRegistrations.filter {
|
|
$0.bracketPosition != nil
|
|
&& ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex
|
|
}
|
|
}
|
|
|
|
var seedsCount = seedsAfterThisRound.count
|
|
if seedsAfterThisRound.isEmpty {
|
|
let nextRoundsDisableMatches = nextRoundsDisableMatches()
|
|
seedsCount = disabledMatches().count - nextRoundsDisableMatches
|
|
}
|
|
|
|
let playedMatches = playedMatches()
|
|
//print("playedMatches \(playedMatches)", initialMode, parent, parentRound?.roundTitle(), seedsAfterThisRound.count)
|
|
let seedInterval = SeedInterval(first: playedMatches.count + seedsCount + 1, last: playedMatches.count * 2 + seedsCount)
|
|
//print(seedInterval.localizedLabel())
|
|
_cachedSeedInterval = seedInterval
|
|
return seedInterval
|
|
|
|
}
|
|
}
|
|
|
|
if let previousRound = previousRound() {
|
|
if (previousRound.enabledMatches().isEmpty == false || initialMode) {
|
|
_cachedSeedInterval = previousRound.seedInterval(initialMode: initialMode)?.chunks()?.first
|
|
return _cachedSeedInterval
|
|
} else {
|
|
_cachedSeedInterval = previousRound.seedInterval(initialMode: initialMode)
|
|
return _cachedSeedInterval
|
|
}
|
|
} else if let parentRound {
|
|
if parentRound.isUpperBracket() {
|
|
_cachedSeedInterval = parentRound.seedInterval(initialMode: initialMode)
|
|
return _cachedSeedInterval
|
|
}
|
|
_cachedSeedInterval = parentRound.seedInterval(initialMode: initialMode)?.chunks()?.last
|
|
return _cachedSeedInterval
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
public func roundTitle(_ displayStyle: DisplayStyle = .wide, initialMode: Bool = false) -> String {
|
|
if groupStageLoserBracket {
|
|
return "Classement Poules"
|
|
}
|
|
|
|
if parent != nil {
|
|
if let seedInterval = seedInterval(initialMode: initialMode) {
|
|
return seedInterval.localizedLabel(displayStyle)
|
|
}
|
|
// print("Round pas trouvé", id, parent, index)
|
|
return "Match de classement"
|
|
}
|
|
return RoundRule.roundName(fromRoundIndex: index, displayStyle: displayStyle)
|
|
}
|
|
|
|
public func updateTournamentState() {
|
|
let tournamentObject = tournamentObject()
|
|
if let tournamentObject, index == 0, isUpperBracket(), hasEnded() {
|
|
tournamentObject.endDate = Date()
|
|
DataStore.shared.tournaments.addOrUpdate(instance: tournamentObject)
|
|
}
|
|
|
|
tournamentObject?.updateTournamentState()
|
|
}
|
|
|
|
public func roundStatus(playedMatches: [Match]) -> String {
|
|
let hasEnded = playedMatches.anySatisfy({ $0.hasEnded() == false }) == false
|
|
let hasStarted = playedMatches.anySatisfy({ $0.hasStarted() })
|
|
if hasStarted && hasEnded == false {
|
|
return "en cours"
|
|
} else if hasEnded {
|
|
return "terminée"
|
|
} else if let tournamentObject = tournamentObject(), tournamentObject.groupStagesAreOver() == false {
|
|
return "en attente"
|
|
} else {
|
|
return "à démarrer"
|
|
}
|
|
}
|
|
|
|
public func loserRounds() -> [Round] {
|
|
if let _cachedLoserRounds {
|
|
return _cachedLoserRounds
|
|
}
|
|
guard let tournamentStore = self.tournamentStore else { return [] }
|
|
#if _DEBUG_TIME //DEBUGING TIME
|
|
let start = Date()
|
|
defer {
|
|
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
|
|
print("func loserRounds: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
|
|
}
|
|
#endif
|
|
|
|
// Filter first to reduce sorting work
|
|
let filteredRounds = tournamentStore.rounds.filter { $0.parent == id }
|
|
|
|
// Return empty array early if no rounds match
|
|
if filteredRounds.isEmpty {
|
|
return []
|
|
}
|
|
|
|
// Sort directly in descending order to avoid the separate reversed() call
|
|
_cachedLoserRounds = filteredRounds.sorted { $0.index > $1.index }
|
|
return _cachedLoserRounds!
|
|
}
|
|
|
|
public func loserRoundsAndChildren() -> [Round] {
|
|
#if _DEBUG_TIME //DEBUGING TIME
|
|
let start = Date()
|
|
defer {
|
|
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
|
|
print("func loserRoundsAndChildren: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
|
|
}
|
|
#endif
|
|
|
|
// Return cached result if available
|
|
if let cached = _cachedLoserRoundsAndChildren {
|
|
return cached
|
|
}
|
|
|
|
// Calculate result if cache is invalid or unavailable
|
|
let direct = loserRounds()
|
|
|
|
// Return quickly if there are no direct loser rounds
|
|
if direct.isEmpty {
|
|
// Update cache with empty result
|
|
_cachedLoserRoundsAndChildren = []
|
|
return []
|
|
}
|
|
|
|
// Pre-allocate capacity to avoid reallocations (estimate based on typical tournament structure)
|
|
var allRounds = direct
|
|
let estimatedChildrenCount = direct.count * 2 // Rough estimate
|
|
allRounds.reserveCapacity(estimatedChildrenCount)
|
|
|
|
// Collect all children rounds in one pass
|
|
for round in direct {
|
|
allRounds.append(contentsOf: round.loserRoundsAndChildren())
|
|
}
|
|
|
|
// Store result in cache
|
|
_cachedLoserRoundsAndChildren = allRounds
|
|
|
|
return allRounds
|
|
}
|
|
|
|
public func isUpperBracket() -> Bool {
|
|
return parent == nil && groupStageLoserBracket == false
|
|
}
|
|
|
|
public func isLoserBracket() -> Bool {
|
|
return parent != nil || groupStageLoserBracket
|
|
}
|
|
|
|
public func deleteLoserBracket() {
|
|
#if DEBUG //DEBUGING TIME
|
|
let start = Date()
|
|
defer {
|
|
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
|
|
print("func deleteLoserBracket: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
|
|
}
|
|
#endif
|
|
self.tournamentStore?.rounds.delete(contentOfs: self.loserRounds())
|
|
self.invalidateCache()
|
|
}
|
|
|
|
public func buildLoserBracket() {
|
|
#if DEBUG //DEBUGING TIME
|
|
let start = Date()
|
|
defer {
|
|
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
|
|
print("func buildLoserBracket: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
|
|
}
|
|
#endif
|
|
guard loserRounds().isEmpty else { return }
|
|
self.invalidateCache()
|
|
let currentRoundMatchCount = RoundRule.numberOfMatches(forRoundIndex: index)
|
|
guard currentRoundMatchCount > 1 else { return }
|
|
guard let tournamentStore else { return }
|
|
let roundCount = RoundRule.numberOfRounds(forTeams: currentRoundMatchCount)
|
|
let loserBracketMatchFormat = tournamentObject()?.loserBracketMatchFormat
|
|
// if let parentRound {
|
|
// loserBracketMatchFormat = tournamentObject()?.loserBracketSmartMatchFormat(parentRound.index)
|
|
// }
|
|
|
|
var titles = [String: String]()
|
|
|
|
let rounds = (0..<roundCount).map { //index 0 is the final
|
|
let round = Round(tournament: tournament, index: $0, matchFormat: loserBracketMatchFormat)
|
|
round.parent = id //parent
|
|
//titles[round.id] = round.roundTitle(initialMode: true)
|
|
return round
|
|
}
|
|
tournamentStore.rounds.addOrUpdate(contentOfs: rounds)
|
|
let matchCount = RoundRule.numberOfMatches(forTeams: currentRoundMatchCount)
|
|
|
|
let matches = (0..<matchCount).map { //0 is final match
|
|
let roundIndex = RoundRule.roundIndex(fromMatchIndex: $0)
|
|
let round = rounds[roundIndex]
|
|
//let title = titles[round.id]
|
|
return Match(round: round.id, index: $0, format: loserBracketMatchFormat)
|
|
//initial mode let the roundTitle give a name without considering the playable match
|
|
}
|
|
|
|
tournamentStore.matches.addOrUpdate(contentOfs: matches)
|
|
|
|
rounds.forEach { round in
|
|
round.buildLoserBracket()
|
|
}
|
|
}
|
|
|
|
public func disableUnplayedLoserBracketMatches() {
|
|
let m = self._matches()
|
|
if let previousRound = self.previousRound() {
|
|
m.forEach { match in
|
|
let prmc = previousRound.previousMatches(previousRound: previousRound, match: match).filter({
|
|
$0.disabled
|
|
}).count
|
|
let byeprmc = previousRound.previousMatches(previousRound: previousRound, match: match).filter({ $0.byeState }).count
|
|
if byeprmc == 2 {
|
|
match.disabled = false
|
|
} else {
|
|
if prmc == 2 {
|
|
match.disabled = true
|
|
if byeprmc == 1 {
|
|
match.byeState = true
|
|
}
|
|
} else if prmc == 1 {
|
|
match.byeState = true
|
|
match.disabled = true
|
|
} else {
|
|
match.disabled = false
|
|
}
|
|
}
|
|
}
|
|
} else if let upperRound = self.parentRound {
|
|
m.forEach { match in
|
|
let prmc = self.upperMatches(upperRound: upperRound, match: match).filter({ $0.disabled }).count
|
|
if prmc > 0 {
|
|
match.disabled = true
|
|
if upperRound.isUpperBracket(), prmc == 1 {
|
|
match.byeState = true
|
|
}
|
|
} else {
|
|
match.disabled = false
|
|
}
|
|
}
|
|
}
|
|
tournamentStore?.matches.addOrUpdate(contentOfs: m)
|
|
|
|
loserRounds().forEach { loserRound in
|
|
loserRound.disableUnplayedLoserBracketMatches()
|
|
}
|
|
}
|
|
|
|
public var parentRound: Round? {
|
|
guard let parent = parent else { return nil }
|
|
return self.tournamentStore?.rounds.findById(parent)
|
|
}
|
|
|
|
public func nextRoundsDisableMatches() -> Int {
|
|
if parent == nil, index > 0 {
|
|
return tournamentObject()?.rounds().suffix(index).flatMap { $0.disabledMatches() }.count ?? 0
|
|
} else {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
public func updateMatchFormat(_ updatedMatchFormat: MatchFormat, checkIfPossible: Bool, andLoserBracket: Bool) {
|
|
if updatedMatchFormat.weight < self.matchFormat.weight {
|
|
updateMatchFormatAndAllMatches(updatedMatchFormat)
|
|
if andLoserBracket {
|
|
loserRoundsAndChildren().forEach { round in
|
|
round.updateMatchFormat(updatedMatchFormat, checkIfPossible: checkIfPossible, andLoserBracket: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public func updateMatchFormatAndAllMatches(_ updatedMatchFormat: MatchFormat) {
|
|
self.matchFormat = updatedMatchFormat
|
|
self.updateMatchFormatOfAllMatches(updatedMatchFormat)
|
|
}
|
|
|
|
public func updateMatchFormatOfAllMatches(_ updatedMatchFormat: MatchFormat) {
|
|
let playedMatches = _unsortedMatches(includeDisabled: true)
|
|
playedMatches.forEach { match in
|
|
match.matchFormat = updatedMatchFormat
|
|
}
|
|
self.tournamentStore?.matches.addOrUpdate(contentOfs: playedMatches)
|
|
}
|
|
|
|
public func invalidateCache() {
|
|
_cachedLoserRounds = nil
|
|
_cachedSeedInterval = nil
|
|
_cachedLoserRoundsAndChildren = nil
|
|
}
|
|
|
|
public override func deleteDependencies(shouldBeSynchronized: Bool) {
|
|
let matches = self._matches()
|
|
for match in matches {
|
|
match.deleteDependencies(shouldBeSynchronized: shouldBeSynchronized)
|
|
}
|
|
|
|
self.tournamentStore?.matches.deleteDependencies(matches, shouldBeSynchronized: shouldBeSynchronized)
|
|
|
|
let loserRounds = self.loserRounds()
|
|
for round in loserRounds {
|
|
round.deleteDependencies(shouldBeSynchronized: shouldBeSynchronized)
|
|
}
|
|
|
|
self.tournamentStore?.rounds.deleteDependencies(loserRounds, shouldBeSynchronized: shouldBeSynchronized)
|
|
}
|
|
|
|
|
|
// enum CodingKeys: String, CodingKey {
|
|
// case _id = "id"
|
|
// case _storeId = "storeId"
|
|
// case _lastUpdate = "lastUpdate"
|
|
// case _tournament = "tournament"
|
|
// case _index = "index"
|
|
// case _parent = "parent"
|
|
// case _format = "format"
|
|
// case _startDate = "startDate"
|
|
// case _groupStageLoserBracket = "groupStageLoserBracket"
|
|
// case _loserBracketMode = "loserBracketMode"
|
|
// }
|
|
//
|
|
// required init(from decoder: Decoder) throws {
|
|
// let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
// id = try container.decode(String.self, forKey: ._id)
|
|
// storeId = try container.decode(String.self, forKey: ._storeId)
|
|
// lastUpdate = try container.decodeIfPresent(Date.self, forKey: ._lastUpdate) ?? Date()
|
|
// tournament = try container.decode(String.self, forKey: ._tournament)
|
|
// index = try container.decode(Int.self, forKey: ._index)
|
|
// parent = try container.decodeIfPresent(String.self, forKey: ._parent)
|
|
// format = try container.decodeIfPresent(MatchFormat.self, forKey: ._format)
|
|
// startDate = try container.decodeIfPresent(Date.self, forKey: ._startDate)
|
|
// groupStageLoserBracket = try container.decodeIfPresent(Bool.self, forKey: ._groupStageLoserBracket) ?? false
|
|
// loserBracketMode = try container.decodeIfPresent(LoserBracketMode.self, forKey: ._loserBracketMode) ?? .automatic
|
|
// }
|
|
//
|
|
// public func encode(to encoder: Encoder) throws {
|
|
// var container = encoder.container(keyedBy: CodingKeys.self)
|
|
//
|
|
// try container.encode(id, forKey: ._id)
|
|
// try container.encode(lastUpdate, forKey: ._lastUpdate)
|
|
// try container.encode(storeId, forKey: ._storeId)
|
|
// try container.encode(tournament, forKey: ._tournament)
|
|
// try container.encode(index, forKey: ._index)
|
|
// try container.encode(groupStageLoserBracket, forKey: ._groupStageLoserBracket)
|
|
// try container.encode(loserBracketMode, forKey: ._loserBracketMode)
|
|
//
|
|
// try container.encode(parent, forKey: ._parent)
|
|
// try container.encode(format, forKey: ._format)
|
|
// try container.encode(startDate, forKey: ._startDate)
|
|
//
|
|
// }
|
|
|
|
func insertOnServer() {
|
|
self.tournamentStore?.rounds.writeChangeAndInsertOnServer(instance: self)
|
|
for match in self._unsortedMatches(includeDisabled: true) {
|
|
match.insertOnServer()
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
extension Round: Selectable {
|
|
|
|
public func selectionLabel(index: Int) -> String {
|
|
if let parentRound {
|
|
return "Tour #\(parentRound.loserRounds().count - index)"
|
|
} else {
|
|
return roundTitle(.short)
|
|
}
|
|
}
|
|
|
|
public func badgeValue() -> Int? {
|
|
#if _DEBUG_TIME //DEBUGING TIME
|
|
let start = Date()
|
|
defer {
|
|
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
|
|
print("func badgeValue round of: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
|
|
}
|
|
#endif
|
|
|
|
|
|
if let parentRound {
|
|
return parentRound.loserRounds(forRoundIndex: index).flatMap { $0.playedMatches() }.filter({ $0.isRunning() }).count
|
|
} else {
|
|
return playedMatches().filter({ $0.isRunning() }).count
|
|
}
|
|
}
|
|
|
|
public func badgeValueColor() -> Color? {
|
|
return nil
|
|
}
|
|
|
|
public func badgeImage() -> Badge? {
|
|
#if _DEBUG_TIME //DEBUGING TIME
|
|
let start = Date()
|
|
defer {
|
|
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
|
|
print("func badgeImage of round: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
|
|
}
|
|
#endif
|
|
return hasEnded() ? .checkmark : nil
|
|
}
|
|
}
|
|
|
|
|
|
public enum LoserBracketMode: Int, CaseIterable, Identifiable, Codable {
|
|
public var id: Int { self.rawValue }
|
|
|
|
case automatic
|
|
case manual
|
|
|
|
public func localizedLoserBracketMode() -> String {
|
|
switch self {
|
|
case .automatic:
|
|
"Automatique"
|
|
case .manual:
|
|
"Manuelle"
|
|
}
|
|
}
|
|
|
|
public func localizedLoserBracketModeDescription() -> String {
|
|
switch self {
|
|
case .automatic:
|
|
"Les perdants du tableau principal sont placés à leur place prévue."
|
|
case .manual:
|
|
"Aucun placement automatique n'est fait. Vous devez choisir les perdants qui se jouent."
|
|
}
|
|
}
|
|
}
|
|
|