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.
516 lines
18 KiB
516 lines
18 KiB
//
|
|
// Round_v2.swift
|
|
// Padel Tournament
|
|
//
|
|
// Created by razmig on 10/03/2024.
|
|
//
|
|
|
|
import Foundation
|
|
import LeStorage
|
|
import SwiftUI
|
|
|
|
@Observable
|
|
class Round: ModelObject, Storable {
|
|
static func resourceName() -> String { "rounds" }
|
|
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
|
|
|
|
var id: String = Store.randomId()
|
|
var tournament: String
|
|
var index: Int
|
|
var parent: String?
|
|
private(set) var format: MatchFormat?
|
|
var startDate: Date?
|
|
|
|
internal init(tournament: String, index: Int, parent: String? = nil, matchFormat: MatchFormat? = nil, startDate: Date? = nil) {
|
|
self.tournament = tournament
|
|
self.index = index
|
|
self.parent = parent
|
|
self.format = matchFormat
|
|
self.startDate = startDate
|
|
}
|
|
|
|
var matchFormat: MatchFormat {
|
|
get {
|
|
format ?? .defaultFormatForMatchType(.bracket)
|
|
}
|
|
set {
|
|
format = newValue
|
|
}
|
|
}
|
|
|
|
|
|
func hasStarted() -> Bool {
|
|
playedMatches().anySatisfy({ $0.hasStarted() })
|
|
}
|
|
|
|
func hasEnded() -> Bool {
|
|
playedMatches().allSatisfy({ $0.hasEnded() })
|
|
}
|
|
|
|
func tournamentObject() -> Tournament? {
|
|
Store.main.findById(tournament)
|
|
}
|
|
|
|
func _matches() -> [Match] {
|
|
Store.main.filter { $0.round == self.id }
|
|
}
|
|
|
|
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 []
|
|
}
|
|
|
|
func previousMatches(ofMatch match: Match) -> [Match] {
|
|
guard let previousRound = previousRound() else { return [] }
|
|
return Store.main.filter {
|
|
($0.index == match.topPreviousRoundMatchIndex() || $0.index == match.bottomPreviousRoundMatchIndex()) && $0.round == previousRound.id
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
func team(_ team: TeamPosition, inMatch match: Match) -> TeamRegistration? {
|
|
switch team {
|
|
case .one:
|
|
return roundProjectedTeam(.one, inMatch: match)
|
|
case .two:
|
|
return roundProjectedTeam(.two, inMatch: match)
|
|
}
|
|
}
|
|
|
|
func seed(_ team: TeamPosition, inMatchIndex matchIndex: Int) -> TeamRegistration? {
|
|
return Store.main.filter(isIncluded: {
|
|
$0.tournament == tournament
|
|
&& $0.bracketPosition != nil
|
|
&& ($0.bracketPosition! / 2) == matchIndex
|
|
&& ($0.bracketPosition! % 2) == team.rawValue
|
|
}).first
|
|
}
|
|
|
|
func seeds(inMatchIndex matchIndex: Int) -> [TeamRegistration] {
|
|
return Store.main.filter(isIncluded: {
|
|
$0.tournament == tournament
|
|
&& $0.bracketPosition != nil
|
|
&& ($0.bracketPosition! / 2) == matchIndex
|
|
})
|
|
}
|
|
|
|
func seeds() -> [TeamRegistration] {
|
|
let initialMatchIndex = RoundRule.matchIndex(fromRoundIndex: index)
|
|
let numberOfMatches = RoundRule.numberOfMatches(forRoundIndex: index)
|
|
return Store.main.filter(isIncluded: {
|
|
$0.tournament == tournament
|
|
&& $0.bracketPosition != nil
|
|
&& ($0.bracketPosition! / 2) >= initialMatchIndex
|
|
&& ($0.bracketPosition! / 2) < initialMatchIndex + numberOfMatches
|
|
})
|
|
}
|
|
|
|
func losers() -> [TeamRegistration] {
|
|
_matches().compactMap { $0.losingTeamId }.compactMap { Store.main.findById($0) }
|
|
}
|
|
|
|
func teams() -> [TeamRegistration] {
|
|
playedMatches().flatMap({ $0.teams() })
|
|
}
|
|
|
|
func roundProjectedTeam(_ team: TeamPosition, inMatch match: Match) -> TeamRegistration? {
|
|
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 parent = upperBracketTopMatch(ofMatchIndex: match.index)?.losingTeamId {
|
|
return Store.main.findById(parent)
|
|
} else if let previousMatch = topPreviousRoundMatch(ofMatch: match) {
|
|
if let teamId = previousMatch.winningTeamId {
|
|
return Store.main.findById(teamId)
|
|
} else if previousMatch.disabled {
|
|
return previousMatch.teams().first
|
|
}
|
|
}
|
|
case .two:
|
|
if let luckyLoser = match.teamScores.first(where: { $0.luckyLoser == match.index * 2 + 1 }) {
|
|
return luckyLoser.team
|
|
} else if let parent = upperBracketBottomMatch(ofMatchIndex: match.index)?.losingTeamId {
|
|
return Store.main.findById(parent)
|
|
} else if let previousMatch = bottomPreviousRoundMatch(ofMatch: match) {
|
|
if let teamId = previousMatch.winningTeamId {
|
|
return Store.main.findById(teamId)
|
|
} else if previousMatch.disabled {
|
|
return previousMatch.teams().first
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func upperBracketTopMatch(ofMatchIndex matchIndex: Int) -> Match? {
|
|
let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex)
|
|
if isLoserBracket(), previousRound() == nil, let parentRound = parentRound, let upperBracketTopMatch = parentRound.getMatch(atMatchIndexInRound: indexInRound * 2) {
|
|
return upperBracketTopMatch
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func upperBracketBottomMatch(ofMatchIndex matchIndex: Int) -> Match? {
|
|
let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex)
|
|
if isLoserBracket(), previousRound() == nil, let parentRound = parentRound, let upperBracketBottomMatch = parentRound.getMatch(atMatchIndexInRound: indexInRound * 2 + 1) {
|
|
return upperBracketBottomMatch
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func topPreviousRoundMatch(ofMatch match: Match) -> Match? {
|
|
guard let previousRound = previousRound() else { return nil }
|
|
return Store.main.filter {
|
|
$0.index == match.topPreviousRoundMatchIndex() && $0.round == previousRound.id
|
|
}.sorted(by: \.index).first
|
|
}
|
|
|
|
func bottomPreviousRoundMatch(ofMatch match: Match) -> Match? {
|
|
guard let previousRound = previousRound() else { return nil }
|
|
return Store.main.filter {
|
|
$0.index == match.bottomPreviousRoundMatchIndex() && $0.round == previousRound.id
|
|
}.sorted(by: \.index).first
|
|
}
|
|
|
|
|
|
func getMatch(atMatchIndexInRound matchIndexInRound: Int) -> Match? {
|
|
Store.main.filter(isIncluded: {
|
|
let index = RoundRule.matchIndexWithinRound(fromMatchIndex: $0.index)
|
|
return $0.round == id && index == matchIndexInRound
|
|
}).first
|
|
}
|
|
|
|
func enabledMatches() -> [Match] {
|
|
Store.main.filter { $0.round == self.id && $0.disabled == false }
|
|
}
|
|
|
|
func displayableMatches() -> [Match] {
|
|
if index == 0 && isUpperBracket() {
|
|
var matches : [Match?] = [playedMatches().first]
|
|
matches.append(loserRounds().first?.playedMatches().first)
|
|
return matches.compactMap({ $0 })
|
|
} else {
|
|
return playedMatches()
|
|
}
|
|
}
|
|
|
|
func playedMatches() -> [Match] {
|
|
if parent == nil {
|
|
enabledMatches()
|
|
} else {
|
|
_matches()
|
|
}
|
|
}
|
|
|
|
func previousRound() -> Round? {
|
|
Store.main.filter(isIncluded: { $0.tournament == tournament && $0.parent == parent && $0.index == index + 1 }).first
|
|
}
|
|
|
|
func nextRound() -> Round? {
|
|
Store.main.filter(isIncluded: { $0.tournament == tournament && $0.parent == parent && $0.index == index - 1 }).first
|
|
}
|
|
|
|
func loserRounds(forRoundIndex roundIndex: Int) -> [Round] {
|
|
return loserRoundsAndChildren().filter({ $0.index == roundIndex }).sorted(by: \.theoryCumulativeMatchCount)
|
|
}
|
|
|
|
func isDisabled() -> Bool {
|
|
_matches().allSatisfy({ $0.disabled })
|
|
}
|
|
|
|
func resetFromRoundAllMatchesStartDate() {
|
|
_matches().forEach({
|
|
$0.startDate = nil
|
|
})
|
|
loserRoundsAndChildren().forEach { round in
|
|
round.resetFromRoundAllMatchesStartDate()
|
|
}
|
|
nextRound()?.resetFromRoundAllMatchesStartDate()
|
|
}
|
|
|
|
func resetFromRoundAllMatchesStartDate(from match: Match) {
|
|
let matches = _matches()
|
|
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()
|
|
}
|
|
|
|
func getActiveLoserRound() -> Round? {
|
|
let rounds = loserRounds().filter({ $0.isDisabled() == false }).sorted(by: \.index).reversed()
|
|
return rounds.first(where: { $0.hasStarted() && $0.hasEnded() == false }) ?? rounds.first
|
|
}
|
|
|
|
func enableRound() {
|
|
_toggleRound(disable: false)
|
|
}
|
|
|
|
func disableRound() {
|
|
_toggleRound(disable: true)
|
|
}
|
|
|
|
private func _toggleRound(disable: Bool) {
|
|
let _matches = _matches()
|
|
_matches.forEach { match in
|
|
match.disabled = disable
|
|
match.resetMatch()
|
|
try? DataStore.shared.teamScores.delete(contentOfs: match.teamScores)
|
|
}
|
|
try? DataStore.shared.matches.addOrUpdate(contentOfs: _matches)
|
|
}
|
|
|
|
var cumulativeMatchCount: Int {
|
|
var totalMatches = playedMatches().count
|
|
if let parentRound {
|
|
totalMatches += parentRound.cumulativeMatchCount
|
|
}
|
|
return totalMatches
|
|
}
|
|
|
|
func initialRound() -> Round? {
|
|
if let parentRound {
|
|
return parentRound.initialRound()
|
|
} else {
|
|
return self
|
|
}
|
|
}
|
|
|
|
func estimatedEndDate(_ additionalEstimationDuration: Int) -> Date? {
|
|
enabledMatches().last?.estimatedEndDate(additionalEstimationDuration)
|
|
}
|
|
|
|
func getLoserRoundStartDate() -> Date? {
|
|
loserRoundsAndChildren().first(where: { $0.isDisabled() == false })?.enabledMatches().first?.startDate
|
|
}
|
|
|
|
func estimatedLoserRoundEndDate(_ additionalEstimationDuration: Int) -> Date? {
|
|
let lastMatch = loserRoundsAndChildren().last(where: { $0.isDisabled() == false })?.enabledMatches().last
|
|
return lastMatch?.estimatedEndDate(additionalEstimationDuration)
|
|
}
|
|
|
|
func disabledMatches() -> [Match] {
|
|
_matches().filter({ $0.disabled })
|
|
}
|
|
|
|
var theoryCumulativeMatchCount: Int {
|
|
var totalMatches = RoundRule.numberOfMatches(forRoundIndex: index)
|
|
if let parentRound {
|
|
totalMatches += parentRound.theoryCumulativeMatchCount
|
|
}
|
|
return totalMatches
|
|
}
|
|
|
|
|
|
func correspondingLoserRoundTitle(_ displayStyle: DisplayStyle = .wide) -> String {
|
|
let initialMatchIndexFromRoundIndex = RoundRule.matchIndex(fromRoundIndex: index)
|
|
let seedsAfterThisRound : [TeamRegistration] = Store.main.filter(isIncluded: {
|
|
$0.tournament == tournament
|
|
&& $0.bracketPosition != nil
|
|
&& ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex
|
|
})
|
|
let playedMatches = playedMatches()
|
|
let seedInterval = SeedInterval(first: playedMatches.count + seedsAfterThisRound.count + 1, last: playedMatches.count * 2 + seedsAfterThisRound.count)
|
|
return seedInterval.localizedLabel(displayStyle)
|
|
}
|
|
|
|
func hasNextRound() -> Bool {
|
|
nextRound()?.isDisabled() == false
|
|
}
|
|
|
|
func seedInterval() -> SeedInterval? {
|
|
if parent == nil {
|
|
let numberOfMatches = RoundRule.numberOfMatches(forRoundIndex: index + 1)
|
|
let initialMatchIndexFromRoundIndex = RoundRule.matchIndex(fromRoundIndex: index)
|
|
let seedsAfterThisRound : [TeamRegistration] = Store.main.filter(isIncluded: {
|
|
$0.tournament == tournament
|
|
&& $0.bracketPosition != nil
|
|
&& ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex
|
|
})
|
|
let playedMatches = playedMatches()
|
|
let reduce = numberOfMatches / 2 - (playedMatches.count + seedsAfterThisRound.count)
|
|
return SeedInterval(first: 1, last: numberOfMatches, reduce: reduce)
|
|
}
|
|
|
|
if let previousRound = previousRound() {
|
|
return previousRound.seedInterval()?.chunks()?.first
|
|
} else if let parentRound {
|
|
return parentRound.seedInterval()?.chunks()?.last
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func roundTitle(_ displayStyle: DisplayStyle = .wide) -> String {
|
|
if parent != nil {
|
|
return seedInterval()?.localizedLabel(displayStyle) ?? "Pas trouvé"
|
|
}
|
|
return RoundRule.roundName(fromRoundIndex: index)
|
|
}
|
|
|
|
func updateTournamentState() {
|
|
if let tournamentObject = tournamentObject(), index == 0, isUpperBracket(), hasEnded() {
|
|
tournamentObject.endDate = Date()
|
|
do {
|
|
try DataStore.shared.tournaments.addOrUpdate(instance: tournamentObject)
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
func roundStatus() -> String {
|
|
if hasStarted() && hasEnded() == false {
|
|
return "en cours"
|
|
} else if hasEnded() {
|
|
return "terminée"
|
|
} else {
|
|
return "à démarrer"
|
|
}
|
|
}
|
|
|
|
func loserRounds() -> [Round] {
|
|
return Store.main.filter(isIncluded: { $0.parent == id }).sorted(by: \.index).reversed()
|
|
}
|
|
|
|
func loserRoundsAndChildren() -> [Round] {
|
|
let loserRounds = loserRounds()
|
|
return loserRounds + loserRounds.flatMap({ $0.loserRoundsAndChildren() })
|
|
}
|
|
|
|
func isUpperBracket() -> Bool {
|
|
parent == nil
|
|
}
|
|
|
|
func isLoserBracket() -> Bool {
|
|
parent != nil
|
|
}
|
|
|
|
func buildLoserBracket() {
|
|
guard loserRounds().isEmpty else { return }
|
|
let currentRoundMatchCount = RoundRule.numberOfMatches(forRoundIndex: index)
|
|
guard currentRoundMatchCount > 1 else { return }
|
|
let roundCount = RoundRule.numberOfRounds(forTeams: currentRoundMatchCount)
|
|
let loserBracketMatchFormat = tournamentObject()?.loserBracketMatchFormat
|
|
|
|
let rounds = (0..<roundCount).map { //index 0 is the final
|
|
let round = Round(tournament: tournament, index: $0, matchFormat: loserBracketMatchFormat)
|
|
round.parent = id //parent
|
|
return round
|
|
}
|
|
|
|
try? DataStore.shared.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]
|
|
return Match(round: round.id, index: $0, matchFormat: loserBracketMatchFormat)
|
|
}
|
|
|
|
print(matches.map {
|
|
(RoundRule.roundName(fromMatchIndex: $0.index), RoundRule.matchIndexWithinRound(fromMatchIndex: $0.index))
|
|
})
|
|
|
|
try? DataStore.shared.matches.addOrUpdate(contentOfs: matches)
|
|
|
|
loserRounds().forEach { round in
|
|
round.buildLoserBracket()
|
|
}
|
|
}
|
|
|
|
var parentRound: Round? {
|
|
guard let parent = parent else { return nil }
|
|
return Store.main.findById(parent)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateMatchFormatAndAllMatches(_ updatedMatchFormat: MatchFormat) {
|
|
self.matchFormat = updatedMatchFormat
|
|
self.updateMatchFormatOfAllMatches(updatedMatchFormat)
|
|
}
|
|
|
|
func updateMatchFormatOfAllMatches(_ updatedMatchFormat: MatchFormat) {
|
|
let playedMatches = _matches()
|
|
playedMatches.forEach { match in
|
|
match.matchFormat = updatedMatchFormat
|
|
}
|
|
try? DataStore.shared.matches.addOrUpdate(contentOfs: playedMatches)
|
|
}
|
|
|
|
override func deleteDependencies() throws {
|
|
try Store.main.deleteDependencies(items: _matches())
|
|
try Store.main.deleteDependencies(items: loserRoundsAndChildren())
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case _id = "id"
|
|
case _tournament = "tournament"
|
|
case _index = "index"
|
|
case _parent = "parent"
|
|
case _format = "format"
|
|
case _startDate = "startDate"
|
|
}
|
|
}
|
|
|
|
extension Round: Selectable {
|
|
func selectionLabel() -> String {
|
|
if let parentRound {
|
|
return "Tour #\(parentRound.loserRounds().count - index)"
|
|
} else {
|
|
return roundTitle()
|
|
}
|
|
}
|
|
|
|
func badgeValue() -> Int? {
|
|
if let parentRound {
|
|
return parentRound.loserRounds(forRoundIndex: index).flatMap { $0.playedMatches() }.filter({ $0.isRunning() }).count
|
|
} else {
|
|
return playedMatches().filter({ $0.isRunning() }).count
|
|
}
|
|
}
|
|
|
|
func badgeValueColor() -> Color? {
|
|
return nil
|
|
}
|
|
|
|
func badgeImage() -> Badge? {
|
|
hasEnded() ? .checkmark : nil
|
|
}
|
|
}
|
|
|