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.
881 lines
42 KiB
881 lines
42 KiB
//
|
|
// MatchScheduler.swift
|
|
// PadelClub
|
|
//
|
|
// Created by Razmig Sarkissian on 08/04/2024.
|
|
//
|
|
|
|
import Foundation
|
|
import LeStorage
|
|
import SwiftUI
|
|
|
|
@Observable
|
|
final class MatchScheduler : ModelObject, Storable {
|
|
|
|
static func resourceName() -> String { return "match-scheduler" }
|
|
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
|
|
static func filterByStoreIdentifier() -> Bool { return false }
|
|
static var relationshipNames: [String] = []
|
|
|
|
private(set) var id: String = Store.randomId()
|
|
var tournament: String
|
|
var timeDifferenceLimit: Int
|
|
var loserBracketRotationDifference: Int
|
|
var upperBracketRotationDifference: Int
|
|
var accountUpperBracketBreakTime: Bool
|
|
var accountLoserBracketBreakTime: Bool
|
|
var randomizeCourts: Bool
|
|
var rotationDifferenceIsImportant: Bool
|
|
var shouldHandleUpperRoundSlice: Bool
|
|
var shouldEndRoundBeforeStartingNext: Bool
|
|
var groupStageChunkCount: Int?
|
|
var overrideCourtsUnavailability: Bool = false
|
|
var shouldTryToFillUpCourtsAvailable: Bool = true
|
|
var courtsAvailable: Set<Int> = Set<Int>()
|
|
|
|
init(tournament: String,
|
|
timeDifferenceLimit: Int = 5,
|
|
loserBracketRotationDifference: Int = 0,
|
|
upperBracketRotationDifference: Int = 1,
|
|
accountUpperBracketBreakTime: Bool = true,
|
|
accountLoserBracketBreakTime: Bool = false,
|
|
randomizeCourts: Bool = true,
|
|
rotationDifferenceIsImportant: Bool = false,
|
|
shouldHandleUpperRoundSlice: Bool = false,
|
|
shouldEndRoundBeforeStartingNext: Bool = true,
|
|
groupStageChunkCount: Int? = nil,
|
|
overrideCourtsUnavailability: Bool = false,
|
|
shouldTryToFillUpCourtsAvailable: Bool = true,
|
|
courtsAvailable: Set<Int> = Set<Int>()) {
|
|
self.tournament = tournament
|
|
self.timeDifferenceLimit = timeDifferenceLimit
|
|
self.loserBracketRotationDifference = loserBracketRotationDifference
|
|
self.upperBracketRotationDifference = upperBracketRotationDifference
|
|
self.accountUpperBracketBreakTime = accountUpperBracketBreakTime
|
|
self.accountLoserBracketBreakTime = accountLoserBracketBreakTime
|
|
self.randomizeCourts = randomizeCourts
|
|
self.rotationDifferenceIsImportant = rotationDifferenceIsImportant
|
|
self.shouldHandleUpperRoundSlice = shouldHandleUpperRoundSlice
|
|
self.shouldEndRoundBeforeStartingNext = shouldEndRoundBeforeStartingNext
|
|
self.groupStageChunkCount = groupStageChunkCount
|
|
self.overrideCourtsUnavailability = overrideCourtsUnavailability
|
|
self.shouldTryToFillUpCourtsAvailable = shouldTryToFillUpCourtsAvailable
|
|
self.courtsAvailable = courtsAvailable
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case _id = "id"
|
|
case _tournament = "tournament"
|
|
case _timeDifferenceLimit = "timeDifferenceLimit"
|
|
case _loserBracketRotationDifference = "loserBracketRotationDifference"
|
|
case _upperBracketRotationDifference = "upperBracketRotationDifference"
|
|
case _accountUpperBracketBreakTime = "accountUpperBracketBreakTime"
|
|
case _accountLoserBracketBreakTime = "accountLoserBracketBreakTime"
|
|
case _randomizeCourts = "randomizeCourts"
|
|
case _rotationDifferenceIsImportant = "rotationDifferenceIsImportant"
|
|
case _shouldHandleUpperRoundSlice = "shouldHandleUpperRoundSlice"
|
|
case _shouldEndRoundBeforeStartingNext = "shouldEndRoundBeforeStartingNext"
|
|
case _groupStageChunkCount = "groupStageChunkCount"
|
|
case _overrideCourtsUnavailability = "overrideCourtsUnavailability"
|
|
case _shouldTryToFillUpCourtsAvailable = "shouldTryToFillUpCourtsAvailable"
|
|
case _courtsAvailable = "courtsAvailable"
|
|
}
|
|
|
|
var courtsUnavailability: [DateInterval]? {
|
|
guard let event = tournamentObject()?.eventObject() else { return nil }
|
|
return event.courtsUnavailability + (overrideCourtsUnavailability ? [] : event.tournamentsCourtsUsed(exluding: tournament))
|
|
}
|
|
|
|
var additionalEstimationDuration : Int {
|
|
return tournamentObject()?.additionalEstimationDuration ?? 0
|
|
}
|
|
|
|
var tournamentStore: TournamentStore {
|
|
return TournamentStore.instance(tournamentId: self.tournament)
|
|
}
|
|
|
|
func tournamentObject() -> Tournament? {
|
|
return Store.main.findById(tournament)
|
|
}
|
|
|
|
@discardableResult
|
|
func updateGroupStageSchedule(tournament: Tournament, specificGroupStage: GroupStage? = nil, atStep step: Int = 0, startDate: Date? = nil) -> Date {
|
|
let computedGroupStageChunkCount = groupStageChunkCount ?? tournament.getGroupStageChunkValue()
|
|
var groupStages: [GroupStage] = tournament.groupStages(atStep: step)
|
|
if let specificGroupStage {
|
|
groupStages = [specificGroupStage]
|
|
}
|
|
|
|
let matches = groupStages.flatMap { $0._matches() }
|
|
matches.forEach({
|
|
$0.removeCourt()
|
|
$0.startDate = nil
|
|
$0.confirmed = false
|
|
})
|
|
|
|
var lastDate : Date = startDate ?? tournament.startDate
|
|
let times = Set(groupStages.compactMap { $0.startDate }).sorted()
|
|
|
|
if let first = times.first {
|
|
if first.isEarlierThan(tournament.startDate) {
|
|
tournament.startDate = first
|
|
do {
|
|
try DataStore.shared.tournaments.addOrUpdate(instance: tournament)
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
times.forEach({ time in
|
|
if lastDate.isEarlierThan(time) {
|
|
lastDate = time
|
|
}
|
|
let groups = groupStages.filter({ $0.startDate == time })
|
|
let dispatch = groupStageDispatcher(groupStages: groups, startingDate: lastDate)
|
|
|
|
dispatch.timedMatches.forEach { matchSchedule in
|
|
if let match = matches.first(where: { $0.id == matchSchedule.matchID }) {
|
|
let estimatedDuration = match.matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration)
|
|
let timeIntervalToAdd = (Double(matchSchedule.rotationIndex)) * Double(estimatedDuration) * 60
|
|
if let startDate = match.groupStageObject?.startDate {
|
|
let matchStartDate = startDate.addingTimeInterval(timeIntervalToAdd)
|
|
match.startDate = matchStartDate
|
|
lastDate = matchStartDate.addingTimeInterval(Double(estimatedDuration) * 60)
|
|
}
|
|
match.setCourt(matchSchedule.courtIndex)
|
|
}
|
|
}
|
|
})
|
|
|
|
groupStages.filter({ $0.startDate == nil || times.contains($0.startDate!) == false }).chunked(into: computedGroupStageChunkCount).forEach { groups in
|
|
groups.forEach({ $0.startDate = lastDate })
|
|
do {
|
|
try self.tournamentStore.groupStages.addOrUpdate(contentOfs: groups)
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
|
|
let dispatch = groupStageDispatcher(groupStages: groups, startingDate: lastDate)
|
|
|
|
dispatch.timedMatches.forEach { matchSchedule in
|
|
if let match = matches.first(where: { $0.id == matchSchedule.matchID }) {
|
|
let estimatedDuration = match.matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration)
|
|
let timeIntervalToAdd = (Double(matchSchedule.rotationIndex)) * Double(estimatedDuration) * 60
|
|
if let startDate = match.groupStageObject?.startDate {
|
|
let matchStartDate = startDate.addingTimeInterval(timeIntervalToAdd)
|
|
match.startDate = matchStartDate
|
|
lastDate = matchStartDate.addingTimeInterval(Double(estimatedDuration) * 60)
|
|
}
|
|
match.setCourt(matchSchedule.courtIndex)
|
|
}
|
|
}
|
|
}
|
|
do {
|
|
try self.tournamentStore.matches.addOrUpdate(contentOfs: matches)
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
return lastDate
|
|
}
|
|
|
|
func groupStageDispatcher(groupStages: [GroupStage], startingDate: Date) -> GroupStageMatchDispatcher {
|
|
|
|
let _groupStages = groupStages
|
|
|
|
// Get the maximum count of matches in any group
|
|
let maxMatchesCount = _groupStages.map { $0._matches().count }.max() ?? 0
|
|
|
|
// Flatten matches in a round-robin order by cycling through each group
|
|
let flattenedMatches = (0..<maxMatchesCount).flatMap { index in
|
|
_groupStages.compactMap { group in
|
|
// Safely access matches, return nil if index is out of bounds
|
|
let playedMatches = group.playedMatches()
|
|
return playedMatches.indices.contains(index) ? playedMatches[index] : nil
|
|
}
|
|
}
|
|
|
|
var slots = [GroupStageTimeMatch]()
|
|
var availableMatches = flattenedMatches
|
|
var rotationIndex = 0
|
|
var teamsPerRotation = [Int: [String]]() // Tracks teams assigned to each rotation
|
|
var freeCourtPerRotation = [Int: [Int]]() // Tracks free courts per rotation
|
|
var groupLastRotation = [Int: Int]() // Tracks the last rotation each group was involved in
|
|
let courtsUnavailability = courtsUnavailability
|
|
|
|
while slots.count < flattenedMatches.count {
|
|
print("Starting rotation \(rotationIndex) with \(availableMatches.count) matches left")
|
|
teamsPerRotation[rotationIndex] = []
|
|
freeCourtPerRotation[rotationIndex] = []
|
|
|
|
let previousRotationBracketIndexes = slots.filter { $0.rotationIndex == rotationIndex - 1 }
|
|
.map { ($0.groupIndex, 1) }
|
|
|
|
let counts = Dictionary(previousRotationBracketIndexes, uniquingKeysWith: +)
|
|
var rotationMatches = Array(availableMatches.filter({ match in
|
|
// Check if all teams from the match are not already scheduled in the current rotation
|
|
let teamsAvailable = teamsPerRotation[rotationIndex]!.allSatisfy({ !match.containsTeamId($0) })
|
|
if !teamsAvailable {
|
|
print("Match \(match.roundAndMatchTitle()) has teams already scheduled in rotation \(rotationIndex)")
|
|
}
|
|
return teamsAvailable
|
|
}).prefix(courtsAvailable.count))
|
|
|
|
if rotationIndex > 0 {
|
|
rotationMatches = rotationMatches.sorted(by: {
|
|
if counts[$0.groupStageObject!.index] ?? 0 == counts[$1.groupStageObject!.index] ?? 0 {
|
|
return $0.groupStageObject!.index < $1.groupStageObject!.index
|
|
} else {
|
|
return counts[$0.groupStageObject!.index] ?? 0 < counts[$1.groupStageObject!.index] ?? 0
|
|
}
|
|
})
|
|
}
|
|
|
|
courtsAvailable.forEach { courtIndex in
|
|
print("Checking availability for court \(courtIndex) in rotation \(rotationIndex)")
|
|
if let first = rotationMatches.first(where: { match in
|
|
let estimatedDuration = match.matchFormat.getEstimatedDuration(additionalEstimationDuration)
|
|
let timeIntervalToAdd = Double(rotationIndex) * Double(estimatedDuration) * 60
|
|
let rotationStartDate: Date = startingDate.addingTimeInterval(timeIntervalToAdd)
|
|
|
|
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: match.matchFormat.getEstimatedDuration(additionalEstimationDuration), courtsUnavailability: courtsUnavailability)
|
|
|
|
if courtsUnavailable.contains(courtIndex) {
|
|
print("Court \(courtIndex) is unavailable at \(rotationStartDate)")
|
|
return false
|
|
}
|
|
|
|
let teamsAvailable = teamsPerRotation[rotationIndex]!.allSatisfy({ !match.containsTeamId($0) })
|
|
if !teamsAvailable {
|
|
print("Teams from match \(match.roundAndMatchTitle()) are already scheduled in this rotation")
|
|
return false
|
|
}
|
|
|
|
print("Match \(match.roundAndMatchTitle()) is available for court \(courtIndex) at \(rotationStartDate)")
|
|
return true
|
|
}) {
|
|
let timeMatch = GroupStageTimeMatch(matchID: first.id, rotationIndex: rotationIndex, courtIndex: courtIndex, groupIndex: first.groupStageObject!.index)
|
|
|
|
print("Scheduled match: \(first.roundAndMatchTitle()) on court \(courtIndex) at rotation \(rotationIndex)")
|
|
|
|
slots.append(timeMatch)
|
|
teamsPerRotation[rotationIndex]!.append(contentsOf: first.teamIds())
|
|
rotationMatches.removeAll(where: { $0.id == first.id })
|
|
availableMatches.removeAll(where: { $0.id == first.id })
|
|
|
|
if let index = first.groupStageObject?.index {
|
|
groupLastRotation[index] = rotationIndex
|
|
}
|
|
} else {
|
|
print("No available matches for court \(courtIndex) in rotation \(rotationIndex), adding to free court list")
|
|
freeCourtPerRotation[rotationIndex]!.append(courtIndex)
|
|
}
|
|
}
|
|
|
|
rotationIndex += 1
|
|
}
|
|
|
|
print("All matches scheduled. Total rotations: \(rotationIndex)")
|
|
|
|
// Organize slots and ensure courts are randomized or sorted
|
|
var organizedSlots = [GroupStageTimeMatch]()
|
|
for i in 0..<rotationIndex {
|
|
let courtsSorted: [Int] = slots.filter({ $0.rotationIndex == i }).map { $0.courtIndex }.sorted()
|
|
let courts: [Int] = randomizeCourts ? courtsSorted.shuffled() : courtsSorted
|
|
var matches = slots.filter({ $0.rotationIndex == i }).sorted(using: .keyPath(\.groupIndex), .keyPath(\.courtIndex))
|
|
|
|
for j in 0..<matches.count {
|
|
matches[j].courtIndex = courts[j]
|
|
organizedSlots.append(matches[j])
|
|
}
|
|
}
|
|
|
|
return GroupStageMatchDispatcher(
|
|
timedMatches: organizedSlots,
|
|
freeCourtPerRotation: freeCourtPerRotation,
|
|
rotationCount: rotationIndex,
|
|
groupLastRotation: groupLastRotation
|
|
)
|
|
}
|
|
|
|
|
|
func rotationDifference(loserBracket: Bool) -> Int {
|
|
if loserBracket {
|
|
return loserBracketRotationDifference
|
|
} else {
|
|
return upperBracketRotationDifference
|
|
}
|
|
}
|
|
|
|
func roundMatchCanBePlayed(_ match: Match, roundObject: Round, slots: [TimeMatch], rotationIndex: Int, targetedStartDate: Date, minimumTargetedEndDate: inout Date) -> Bool {
|
|
print("Evaluating match: \(match.roundAndMatchTitle()) in round: \(roundObject.roundTitle()) with index: \(match.index)")
|
|
|
|
if let roundStartDate = roundObject.startDate, targetedStartDate < roundStartDate {
|
|
print("Cannot start at \(targetedStartDate), earlier than round start date \(roundStartDate)")
|
|
if targetedStartDate == minimumTargetedEndDate {
|
|
print("Updating minimumTargetedEndDate to roundStartDate: \(roundStartDate)")
|
|
minimumTargetedEndDate = roundStartDate
|
|
} else {
|
|
print("Setting minimumTargetedEndDate to the earlier of \(roundStartDate) and \(minimumTargetedEndDate)")
|
|
minimumTargetedEndDate = min(roundStartDate, minimumTargetedEndDate)
|
|
}
|
|
print("Returning false: Match cannot start earlier than the round start date.")
|
|
return false
|
|
}
|
|
|
|
let previousMatches = roundObject.precedentMatches(ofMatch: match)
|
|
if previousMatches.isEmpty {
|
|
print("No ancestors matches for this match, returning true. (eg beginning of tournament 1st bracket")
|
|
return true
|
|
}
|
|
|
|
let previousMatchSlots = slots.filter { previousMatches.map { $0.id }.contains($0.matchID) }
|
|
|
|
if previousMatchSlots.isEmpty {
|
|
if previousMatches.filter({ !$0.disabled }).allSatisfy({ $0.startDate != nil }) {
|
|
print("All previous matches have start dates, returning true.")
|
|
return true
|
|
}
|
|
print("Some previous matches are pending, returning false.")
|
|
return false
|
|
}
|
|
|
|
if previousMatches.filter({ !$0.disabled }).count > previousMatchSlots.count {
|
|
if previousMatches.filter({ !$0.disabled }).anySatisfy({ $0.startDate != nil }) {
|
|
print("Some previous matches started, returning true.")
|
|
return true
|
|
}
|
|
print("Not enough previous matches have started, returning false.")
|
|
return false
|
|
}
|
|
|
|
var includeBreakTime = false
|
|
if accountLoserBracketBreakTime && roundObject.isLoserBracket() {
|
|
includeBreakTime = true
|
|
print("Including break time for loser bracket.")
|
|
}
|
|
|
|
if accountUpperBracketBreakTime && !roundObject.isLoserBracket() {
|
|
includeBreakTime = true
|
|
print("Including break time for upper bracket.")
|
|
}
|
|
|
|
let previousMatchIsInPreviousRotation = previousMatchSlots.allSatisfy {
|
|
$0.rotationIndex + rotationDifference(loserBracket: roundObject.isLoserBracket()) < rotationIndex
|
|
}
|
|
|
|
if previousMatchIsInPreviousRotation {
|
|
print("All previous matches are from earlier rotations, returning true.")
|
|
} else {
|
|
print("Some previous matches are from the current rotation.")
|
|
}
|
|
|
|
guard let minimumPossibleEndDate = previousMatchSlots.map({
|
|
$0.estimatedEndDate(includeBreakTime: includeBreakTime)
|
|
}).max() else {
|
|
print("No valid previous match end date, returning \(previousMatchIsInPreviousRotation).")
|
|
return previousMatchIsInPreviousRotation
|
|
}
|
|
|
|
if targetedStartDate >= minimumPossibleEndDate {
|
|
if rotationDifferenceIsImportant {
|
|
print("Targeted start date is after the minimum possible end date and rotation difference is important, returning \(previousMatchIsInPreviousRotation).")
|
|
return previousMatchIsInPreviousRotation
|
|
} else {
|
|
print("Targeted start date is after the minimum possible end date, returning true.")
|
|
return true
|
|
}
|
|
} else {
|
|
if targetedStartDate == minimumTargetedEndDate {
|
|
print("Updating minimumTargetedEndDate to minimumPossibleEndDate: \(minimumPossibleEndDate)")
|
|
minimumTargetedEndDate = minimumPossibleEndDate
|
|
} else {
|
|
print("Setting minimumTargetedEndDate to the earlier of \(minimumPossibleEndDate) and \(minimumTargetedEndDate)")
|
|
minimumTargetedEndDate = min(minimumPossibleEndDate, minimumTargetedEndDate)
|
|
}
|
|
print("Targeted start date \(targetedStartDate) is before the minimum possible end date, returning false. \(minimumTargetedEndDate)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
|
|
func getNextStartDate(fromPreviousRotationSlots slots: [TimeMatch], includeBreakTime: Bool) -> Date? {
|
|
slots.map { $0.estimatedEndDate(includeBreakTime: includeBreakTime) }.min()
|
|
}
|
|
|
|
func getNextEarliestAvailableDate(from slots: [TimeMatch]) -> [(Int, Date)] {
|
|
let byCourt = Dictionary(grouping: slots, by: { $0.courtIndex })
|
|
return (byCourt.keys.flatMap { courtIndex in
|
|
let matchesByCourt = byCourt[courtIndex]?.sorted(by: \.startDate)
|
|
let lastMatch = matchesByCourt?.last
|
|
var results = [(Int, Date)]()
|
|
if let courtFreeDate = lastMatch?.estimatedEndDate(includeBreakTime: false) {
|
|
results.append((courtIndex, courtFreeDate))
|
|
}
|
|
return results
|
|
}
|
|
)
|
|
}
|
|
|
|
func getAvailableCourts(from matches: [Match]) -> [(Int, Date)] {
|
|
let validMatches = matches.filter({ $0.courtIndex != nil && $0.startDate != nil })
|
|
let byCourt = Dictionary(grouping: validMatches, by: { $0.courtIndex! })
|
|
return (byCourt.keys.flatMap { court in
|
|
let matchesByCourt = byCourt[court]?.sorted(by: \.startDate!)
|
|
let lastMatch = matchesByCourt?.last
|
|
var results = [(Int, Date)]()
|
|
if let courtFreeDate = lastMatch?.estimatedEndDate(additionalEstimationDuration) {
|
|
results.append((court, courtFreeDate))
|
|
}
|
|
return results
|
|
}
|
|
)
|
|
}
|
|
|
|
func roundDispatcher(flattenedMatches: [Match], dispatcherStartDate: Date, initialCourts: [Int]?) -> MatchDispatcher {
|
|
var slots = [TimeMatch]()
|
|
var _startDate: Date?
|
|
var rotationIndex = 0
|
|
var availableMatchs = flattenedMatches.filter({ $0.startDate == nil })
|
|
let courtsUnavailability = courtsUnavailability
|
|
var issueFound: Bool = false
|
|
|
|
// Log start of the function
|
|
print("Starting roundDispatcher with \(availableMatchs.count) matches and \(courtsAvailable) courts available")
|
|
|
|
flattenedMatches.filter { $0.startDate != nil }.sorted(by: \.startDate!).forEach { match in
|
|
if _startDate == nil {
|
|
_startDate = match.startDate
|
|
} else if match.startDate! > _startDate! {
|
|
_startDate = match.startDate
|
|
rotationIndex += 1
|
|
}
|
|
|
|
let timeMatch = TimeMatch(matchID: match.id, rotationIndex: rotationIndex, courtIndex: match.courtIndex ?? 0, startDate: match.startDate!, durationLeft: match.matchFormat.getEstimatedDuration(additionalEstimationDuration), minimumBreakTime: match.matchFormat.breakTime.breakTime)
|
|
slots.append(timeMatch)
|
|
}
|
|
|
|
if !slots.isEmpty {
|
|
rotationIndex += 1
|
|
}
|
|
|
|
var freeCourtPerRotation = [Int: [Int]]()
|
|
var courts = initialCourts ?? Array(courtsAvailable)
|
|
var shouldStartAtDispatcherDate = rotationIndex > 0
|
|
var suitableDate: Date?
|
|
|
|
while !availableMatchs.isEmpty && !issueFound && rotationIndex < 50 {
|
|
freeCourtPerRotation[rotationIndex] = []
|
|
let previousRotationSlots = slots.filter({ $0.rotationIndex == rotationIndex - 1 })
|
|
|
|
var rotationStartDate: Date
|
|
if previousRotationSlots.isEmpty && rotationIndex > 0 {
|
|
let computedSuitableDate = slots.sorted(by: \.computedEndDateForSorting).last?.computedEndDateForSorting
|
|
print("Previous rotation was empty, find a suitable rotationStartDate \(suitableDate)")
|
|
rotationStartDate = suitableDate ?? computedSuitableDate ?? dispatcherStartDate
|
|
} else {
|
|
rotationStartDate = getNextStartDate(fromPreviousRotationSlots: previousRotationSlots, includeBreakTime: false) ?? dispatcherStartDate
|
|
}
|
|
|
|
if shouldStartAtDispatcherDate {
|
|
rotationStartDate = dispatcherStartDate
|
|
shouldStartAtDispatcherDate = false
|
|
} else {
|
|
courts = rotationIndex == 0 ? courts : Array(courtsAvailable)
|
|
}
|
|
courts.sort()
|
|
|
|
// Log courts availability and start date
|
|
print("Courts available at rotation \(rotationIndex): \(courts)")
|
|
print("Rotation start date: \(rotationStartDate)")
|
|
|
|
// Check for court availability and break time conflicts
|
|
if rotationIndex > 0, let freeCourtPreviousRotation = freeCourtPerRotation[rotationIndex - 1], !freeCourtPreviousRotation.isEmpty {
|
|
print("Handling break time conflicts or waiting for free courts")
|
|
let previousPreviousRotationSlots = slots.filter { $0.rotationIndex == rotationIndex - 2 && freeCourtPreviousRotation.contains($0.courtIndex) }
|
|
let previousEndDate = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: accountUpperBracketBreakTime)
|
|
let previousEndDateNoBreak = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: false)
|
|
|
|
let noBreakAlreadyTested = previousRotationSlots.anySatisfy { $0.startDate == previousEndDateNoBreak }
|
|
|
|
if let previousEndDate, let previousEndDateNoBreak {
|
|
let differenceWithBreak = rotationStartDate.timeIntervalSince(previousEndDate)
|
|
let differenceWithoutBreak = rotationStartDate.timeIntervalSince(previousEndDateNoBreak)
|
|
print("Difference with break: \(differenceWithBreak), without break: \(differenceWithoutBreak)")
|
|
|
|
let timeDifferenceLimitInSeconds = Double(timeDifferenceLimit * 60)
|
|
var difference = differenceWithBreak
|
|
|
|
if differenceWithBreak <= 0, accountUpperBracketBreakTime == false {
|
|
difference = differenceWithoutBreak
|
|
} else if differenceWithBreak > timeDifferenceLimitInSeconds && differenceWithoutBreak > timeDifferenceLimitInSeconds {
|
|
difference = noBreakAlreadyTested ? differenceWithBreak : max(differenceWithBreak, differenceWithoutBreak)
|
|
}
|
|
|
|
print("Final difference to evaluate: \(difference)")
|
|
|
|
if (difference > timeDifferenceLimitInSeconds && rotationStartDate.addingTimeInterval(-difference) != previousEndDate) || difference < 0 {
|
|
print("""
|
|
Adjusting rotation start:
|
|
- Initial rotationStartDate: \(rotationStartDate)
|
|
- Adjusted by difference: \(difference)
|
|
- Adjusted rotationStartDate: \(rotationStartDate.addingTimeInterval(-difference))
|
|
- PreviousEndDate: \(previousEndDate)
|
|
""")
|
|
|
|
courts.removeAll(where: { freeCourtPreviousRotation.contains($0) })
|
|
freeCourtPerRotation[rotationIndex] = courts
|
|
courts = freeCourtPreviousRotation
|
|
rotationStartDate = rotationStartDate.addingTimeInterval(-difference)
|
|
}
|
|
}
|
|
} else if let firstMatch = availableMatchs.first {
|
|
let duration = firstMatch.matchFormat.getEstimatedDuration(additionalEstimationDuration)
|
|
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: duration, courtsUnavailability: courtsUnavailability)
|
|
|
|
if Array(Set(courtsAvailable).subtracting(Set(courtsUnavailable))).isEmpty {
|
|
print("Issue: All courts unavailable in this rotation")
|
|
if let courtsUnavailability {
|
|
let computedStartDateAndCourts = getFirstFreeCourt(startDate: rotationStartDate, duration: duration, courts: courts, courtsUnavailability: courtsUnavailability)
|
|
rotationStartDate = computedStartDateAndCourts.earliestFreeDate
|
|
courts = computedStartDateAndCourts.availableCourts
|
|
} else {
|
|
issueFound = true
|
|
}
|
|
} else {
|
|
courts = Array(Set(courtsAvailable).subtracting(Set(courtsUnavailable)))
|
|
}
|
|
}
|
|
|
|
// Dispatch courts and schedule matches
|
|
suitableDate = dispatchCourts(courts: courts, availableMatchs: &availableMatchs, slots: &slots, rotationIndex: rotationIndex, rotationStartDate: rotationStartDate, freeCourtPerRotation: &freeCourtPerRotation, courtsUnavailability: courtsUnavailability)
|
|
rotationIndex += 1
|
|
}
|
|
|
|
// Organize matches in slots
|
|
var organizedSlots = [TimeMatch]()
|
|
for i in 0..<rotationIndex {
|
|
let courtsSorted = slots.filter { $0.rotationIndex == i }.map { $0.courtIndex }.sorted()
|
|
let courts = randomizeCourts ? courtsSorted.shuffled() : courtsSorted
|
|
var matches = slots.filter { $0.rotationIndex == i }.sorted(using: .keyPath(\.courtIndex))
|
|
|
|
for j in 0..<matches.count {
|
|
matches[j].courtIndex = courts[j]
|
|
organizedSlots.append(matches[j])
|
|
}
|
|
}
|
|
|
|
print("Finished roundDispatcher with \(organizedSlots.count) scheduled matches")
|
|
|
|
return MatchDispatcher(timedMatches: slots, freeCourtPerRotation: freeCourtPerRotation, rotationCount: rotationIndex, issueFound: issueFound)
|
|
}
|
|
|
|
func dispatchCourts(courts: [Int], availableMatchs: inout [Match], slots: inout [TimeMatch], rotationIndex: Int, rotationStartDate: Date, freeCourtPerRotation: inout [Int: [Int]], courtsUnavailability: [DateInterval]?) -> Date {
|
|
var matchPerRound = [String: Int]()
|
|
var minimumTargetedEndDate = rotationStartDate
|
|
|
|
// Log dispatch attempt
|
|
print("Dispatching courts for rotation \(rotationIndex) with start date \(rotationStartDate) and available courts \(courts.sorted())")
|
|
|
|
for (courtPosition, courtIndex) in courts.sorted().enumerated() {
|
|
if let firstMatch = availableMatchs.first(where: { match in
|
|
print("Trying to find a match for court \(courtIndex) in rotation \(rotationIndex)")
|
|
|
|
let roundObject = match.roundObject!
|
|
let duration = match.matchFormat.getEstimatedDuration(additionalEstimationDuration)
|
|
|
|
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: duration, courtsUnavailability: courtsUnavailability)
|
|
|
|
if courtsUnavailable.contains(courtIndex) {
|
|
print("Returning false: Court \(courtIndex) unavailable due to schedule conflicts during \(rotationStartDate).")
|
|
return false
|
|
}
|
|
|
|
let canBePlayed = roundMatchCanBePlayed(match, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &minimumTargetedEndDate)
|
|
|
|
if !canBePlayed {
|
|
print("Returning false: Match \(match.roundAndMatchTitle()) can't be played due to constraints.")
|
|
return false
|
|
}
|
|
|
|
let currentRotationSameRoundMatches = matchPerRound[roundObject.id] ?? 0
|
|
let roundMatchesCount = roundObject.playedMatches().count
|
|
|
|
if shouldHandleUpperRoundSlice {
|
|
if roundObject.parent == nil && roundMatchesCount > courts.count && currentRotationSameRoundMatches >= min(roundMatchesCount / 2, courts.count) {
|
|
print("Returning false: Too many matches already played in the current rotation for round \(roundObject.roundTitle()).")
|
|
return false
|
|
}
|
|
}
|
|
|
|
let indexInRound = match.indexInRound()
|
|
|
|
|
|
if shouldTryToFillUpCourtsAvailable == false {
|
|
if roundObject.parent == nil && roundObject.index > 1 && indexInRound == 0, let nextMatch = match.next() {
|
|
|
|
var nextMinimumTargetedEndDate = minimumTargetedEndDate
|
|
if courtPosition < courts.count - 1 && canBePlayed && roundMatchCanBePlayed(nextMatch, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &nextMinimumTargetedEndDate) {
|
|
print("Returning true: Both current \(match.index) and next match \(nextMatch.index) can be played in rotation \(rotationIndex).")
|
|
return true
|
|
} else {
|
|
print("Returning false: Either current match or next match cannot be played in rotation \(rotationIndex).")
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
print("Returning true: Match \(match.roundAndMatchTitle()) can be played on court \(courtIndex).")
|
|
return canBePlayed
|
|
}) {
|
|
print("Found match: \(firstMatch.roundAndMatchTitle()) for court \(courtIndex) at \(rotationStartDate)")
|
|
|
|
matchPerRound[firstMatch.roundObject!.id, default: 0] += 1
|
|
|
|
let timeMatch = TimeMatch(
|
|
matchID: firstMatch.id,
|
|
rotationIndex: rotationIndex,
|
|
courtIndex: courtIndex,
|
|
startDate: rotationStartDate,
|
|
durationLeft: firstMatch.matchFormat.getEstimatedDuration(additionalEstimationDuration),
|
|
minimumBreakTime: firstMatch.matchFormat.breakTime.breakTime
|
|
)
|
|
|
|
slots.append(timeMatch)
|
|
availableMatchs.removeAll(where: { $0.id == firstMatch.id })
|
|
} else {
|
|
print("No suitable match found for court \(courtIndex) in rotation \(rotationIndex). Adding court to freeCourtPerRotation.")
|
|
freeCourtPerRotation[rotationIndex]?.append(courtIndex)
|
|
}
|
|
|
|
}
|
|
|
|
if freeCourtPerRotation[rotationIndex]?.count == courtsAvailable.count {
|
|
print("All courts in rotation \(rotationIndex) are free")
|
|
}
|
|
|
|
return minimumTargetedEndDate
|
|
}
|
|
|
|
@discardableResult func updateBracketSchedule(tournament: Tournament, fromRoundId roundId: String?, fromMatchId matchId: String?, startDate: Date) -> Bool {
|
|
|
|
let upperRounds: [Round] = tournament.rounds()
|
|
let allMatches: [Match] = tournament.allMatches()
|
|
|
|
var rounds = [Round]()
|
|
|
|
if let groupStageLoserBracketRound = tournament.groupStageLoserBracket() {
|
|
rounds.append(groupStageLoserBracketRound)
|
|
}
|
|
|
|
if shouldEndRoundBeforeStartingNext {
|
|
rounds.append(contentsOf: upperRounds.flatMap {
|
|
[$0] + $0.loserRoundsAndChildren()
|
|
})
|
|
} else {
|
|
rounds.append(contentsOf: upperRounds.map {
|
|
$0
|
|
} + upperRounds.flatMap {
|
|
$0.loserRoundsAndChildren()
|
|
})
|
|
}
|
|
|
|
let flattenedMatches = rounds.flatMap { round in
|
|
round._matches().filter({ $0.disabled == false }).sorted(by: \.index)
|
|
}
|
|
|
|
flattenedMatches.forEach({
|
|
if (roundId == nil && matchId == nil) || $0.startDate?.isEarlierThan(startDate) == false {
|
|
$0.startDate = nil
|
|
$0.removeCourt()
|
|
$0.confirmed = false
|
|
}
|
|
})
|
|
|
|
// if let roundId {
|
|
// if let round : Round = Store.main.findById(roundId) {
|
|
// let matches = round._matches().filter({ $0.disabled == false }).sorted(by: \.index)
|
|
// round.resetFromRoundAllMatchesStartDate()
|
|
// flattenedMatches = matches + flattenedMatches
|
|
// }
|
|
//
|
|
// } else if let matchId {
|
|
// if let match : Match = Store.main.findById(matchId) {
|
|
// if let round = match.roundObject {
|
|
// round.resetFromRoundAllMatchesStartDate(from: match)
|
|
// }
|
|
// flattenedMatches = [match] + flattenedMatches
|
|
// }
|
|
// }
|
|
|
|
if let roundId, let matchId {
|
|
//todo
|
|
if let index = flattenedMatches.firstIndex(where: { $0.round == roundId && $0.id == matchId }) {
|
|
flattenedMatches[index...].forEach {
|
|
$0.startDate = nil
|
|
$0.removeCourt()
|
|
$0.confirmed = false
|
|
}
|
|
}
|
|
} else if let roundId {
|
|
//todo
|
|
if let index = flattenedMatches.firstIndex(where: { $0.round == roundId }) {
|
|
flattenedMatches[index...].forEach {
|
|
$0.startDate = nil
|
|
$0.removeCourt()
|
|
$0.confirmed = false
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
let matches: [Match] = allMatches.filter { $0.startDate?.isEarlierThan(startDate) == true && $0.startDate?.dayInt == startDate.dayInt }
|
|
let usedCourts = getAvailableCourts(from: matches)
|
|
let initialCourts: [Int] = usedCourts.filter { (court, availableDate) in
|
|
availableDate <= startDate
|
|
}.sorted(by: \.1).compactMap { $0.0 }
|
|
|
|
let courts : [Int]? = initialCourts.isEmpty ? nil : initialCourts
|
|
|
|
print("initial available courts at beginning: \(courts ?? [])")
|
|
|
|
let roundDispatch = self.roundDispatcher(flattenedMatches: flattenedMatches, dispatcherStartDate: startDate, initialCourts: courts)
|
|
|
|
roundDispatch.timedMatches.forEach { matchSchedule in
|
|
if let match = flattenedMatches.first(where: { $0.id == matchSchedule.matchID }) {
|
|
match.startDate = matchSchedule.startDate
|
|
match.setCourt(matchSchedule.courtIndex)
|
|
}
|
|
}
|
|
|
|
do {
|
|
try self.tournamentStore.matches.addOrUpdate(contentOfs: allMatches)
|
|
} catch {
|
|
Logger.error(error)
|
|
}
|
|
|
|
return roundDispatch.issueFound
|
|
}
|
|
|
|
|
|
func courtsUnavailable(startDate: Date, duration: Int, courtsUnavailability: [DateInterval]?) -> [Int] {
|
|
let endDate = startDate.addingTimeInterval(Double(duration) * 60)
|
|
guard let courtsUnavailability else { return [] }
|
|
let groupedBy = Dictionary(grouping: courtsUnavailability, by: { $0.courtIndex })
|
|
let courts = groupedBy.keys
|
|
return courts.filter {
|
|
courtUnavailable(courtIndex: $0, from: startDate, to: endDate, source: courtsUnavailability)
|
|
}
|
|
}
|
|
|
|
func courtUnavailable(courtIndex: Int, from startDate: Date, to endDate: Date, source: [DateInterval]) -> Bool {
|
|
let courtLockedSchedule = source.filter({ $0.courtIndex == courtIndex })
|
|
return courtLockedSchedule.anySatisfy({ dateInterval in
|
|
let range = startDate..<endDate
|
|
return dateInterval.range.overlaps(range)
|
|
})
|
|
}
|
|
|
|
func getFirstFreeCourt(startDate: Date, duration: Int, courts: [Int], courtsUnavailability: [DateInterval]) -> (earliestFreeDate: Date, availableCourts: [Int]) {
|
|
var earliestEndDate: Date?
|
|
var availableCourtsAtEarliest: [Int] = []
|
|
|
|
// Iterate through each court and find the earliest time it becomes free
|
|
for courtIndex in courts {
|
|
let unavailabilityForCourt = courtsUnavailability.filter { $0.courtIndex == courtIndex }
|
|
var isAvailable = true
|
|
|
|
for interval in unavailabilityForCourt {
|
|
if interval.startDate <= startDate && interval.endDate > startDate {
|
|
isAvailable = false
|
|
if let currentEarliest = earliestEndDate {
|
|
earliestEndDate = min(currentEarliest, interval.endDate)
|
|
} else {
|
|
earliestEndDate = interval.endDate
|
|
}
|
|
}
|
|
}
|
|
|
|
// If the court is available at the start date, add it to the list of available courts
|
|
if isAvailable {
|
|
availableCourtsAtEarliest.append(courtIndex)
|
|
}
|
|
}
|
|
|
|
// If there are no unavailable courts, return the original start date and all courts
|
|
if let earliestEndDate = earliestEndDate {
|
|
// Find which courts will be available at the earliest free date
|
|
let courtsAvailableAtEarliest = courts.filter { courtIndex in
|
|
let unavailabilityForCourt = courtsUnavailability.filter { $0.courtIndex == courtIndex }
|
|
return unavailabilityForCourt.allSatisfy { $0.endDate <= earliestEndDate }
|
|
}
|
|
return (earliestFreeDate: earliestEndDate, availableCourts: courtsAvailableAtEarliest)
|
|
} else {
|
|
// If no courts were unavailable, all courts are available at the start date
|
|
return (earliestFreeDate: startDate.addingTimeInterval(Double(duration) * 60), availableCourts: courts)
|
|
}
|
|
}
|
|
|
|
func updateSchedule(tournament: Tournament) -> Bool {
|
|
if tournament.courtCount < courtsAvailable.count {
|
|
courtsAvailable = Set(tournament.courtsAvailable())
|
|
}
|
|
var lastDate = tournament.startDate
|
|
if tournament.groupStageCount > 0 {
|
|
lastDate = updateGroupStageSchedule(tournament: tournament)
|
|
}
|
|
if tournament.groupStages(atStep: 1).isEmpty == false {
|
|
lastDate = updateGroupStageSchedule(tournament: tournament, atStep: 1, startDate: lastDate)
|
|
}
|
|
return updateBracketSchedule(tournament: tournament, fromRoundId: nil, fromMatchId: nil, startDate: lastDate)
|
|
}
|
|
}
|
|
|
|
struct GroupStageTimeMatch {
|
|
let matchID: String
|
|
let rotationIndex: Int
|
|
var courtIndex: Int
|
|
let groupIndex: Int
|
|
}
|
|
|
|
struct TimeMatch {
|
|
let matchID: String
|
|
let rotationIndex: Int
|
|
var courtIndex: Int
|
|
var startDate: Date
|
|
var durationLeft: Int //in minutes
|
|
var minimumBreakTime: Int //in minutes
|
|
|
|
func estimatedEndDate(includeBreakTime: Bool) -> Date {
|
|
let minutesToAdd = Double(durationLeft + (includeBreakTime ? minimumBreakTime : 0))
|
|
return startDate.addingTimeInterval(minutesToAdd * 60.0)
|
|
}
|
|
|
|
var computedEndDateForSorting: Date {
|
|
estimatedEndDate(includeBreakTime: false)
|
|
}
|
|
}
|
|
|
|
struct GroupStageMatchDispatcher {
|
|
let timedMatches: [GroupStageTimeMatch]
|
|
let freeCourtPerRotation: [Int: [Int]]
|
|
let rotationCount: Int
|
|
let groupLastRotation: [Int: Int]
|
|
}
|
|
|
|
struct MatchDispatcher {
|
|
let timedMatches: [TimeMatch]
|
|
let freeCourtPerRotation: [Int: [Int]]
|
|
let rotationCount: Int
|
|
let issueFound: Bool
|
|
}
|
|
|
|
extension Match {
|
|
func teamIds() -> [String] {
|
|
return teams().map { $0.id }
|
|
}
|
|
|
|
func containsTeamId(_ id: String) -> Bool {
|
|
return teamIds().contains(id)
|
|
}
|
|
}
|
|
|