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.
 
 
PadelClub/PadelClub/Data/Match.swift

644 lines
22 KiB

//
// Match_v2.swift
// Padel Tournament
//
// Created by razmig on 10/03/2024.
//
import Foundation
import LeStorage
@Observable
class Match: ModelObject, Storable {
static func resourceName() -> String { "matches" }
var byeState: Bool = false
var id: String = Store.randomId()
var round: String?
var groupStage: String?
var startDate: Date?
var endDate: Date?
var index: Int
private var format: MatchFormat?
//var court: String?
var servingTeamId: String?
var winningTeamId: String?
var losingTeamId: String?
//var broadcasted: Bool
//var name: String?
//var order: Int
var disabled: Bool = false
private(set) var courtIndex: Int?
internal init(round: String? = nil, groupStage: String? = nil, startDate: Date? = nil, endDate: Date? = nil, index: Int, matchFormat: MatchFormat? = nil, servingTeamId: String? = nil, winningTeamId: String? = nil, losingTeamId: String? = nil) {
self.round = round
self.groupStage = groupStage
self.startDate = startDate
self.endDate = endDate
self.index = index
self.format = matchFormat
//self.court = court
self.servingTeamId = servingTeamId
self.winningTeamId = winningTeamId
self.losingTeamId = losingTeamId
// self.broadcasted = broadcasted
// self.name = name
// self.order = order
}
func indexInRound() -> Int {
if groupStage != nil {
return index
} else if let index = roundObject?.playedMatches().firstIndex(where: { $0.id == id }) {
return index
}
return RoundRule.matchIndexWithinRound(fromMatchIndex: index)
}
func matchWarningSubject() -> String {
[roundTitle(), matchTitle()].compacted().joined(separator: " ")
}
func matchWarningMessage() -> String {
[roundTitle(), matchTitle(), startDate?.localizedDate(), courtName()].compacted().joined(separator: "\n")
}
func matchTitle(_ displayStyle: DisplayStyle = .wide) -> String {
if let groupStageObject {
return groupStageObject.localizedMatchUpLabel(for: index)
}
switch displayStyle {
case .wide:
return "Match \(indexInRound() + 1)"
case .short:
return "#\(indexInRound() + 1)"
}
}
func isSeedLocked(atTeamPosition teamPosition: TeamPosition) -> Bool {
previousMatch(teamPosition)?.disabled == true
}
func unlockSeedPosition(atTeamPosition teamPosition: TeamPosition) {
previousMatch(teamPosition)?.enableMatch()
}
@discardableResult
func lockAndGetSeedPosition(atTeamPosition slot: TeamPosition?, opposingSeeding: Bool = false) -> Int {
let matchIndex = index
var teamPosition : TeamPosition {
if let slot {
return slot
} else {
let seedRound = RoundRule.roundIndex(fromMatchIndex: matchIndex)
let numberOfMatches = RoundRule.numberOfMatches(forRoundIndex: seedRound)
let isUpper = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex) < (numberOfMatches / 2)
var teamPosition = slot ?? (isUpper ? .one : .two)
if opposingSeeding {
teamPosition = slot ?? (isUpper ? .two : .one)
}
return teamPosition
}
}
previousMatch(teamPosition)?.disableMatch()
return matchIndex * 2 + teamPosition.rawValue
}
func isSeededBy(team: TeamRegistration, inTeamPosition teamPosition: TeamPosition) -> Bool {
guard let bracketPosition = team.bracketPosition else { return false }
return index * 2 + teamPosition.rawValue == bracketPosition
}
func estimatedEndDate(_ additionalEstimationDuration: Int) -> Date? {
let minutesToAdd = Double(matchFormat.getEstimatedDuration(additionalEstimationDuration))
return startDate?.addingTimeInterval(minutesToAdd * 60.0)
}
func resetMatch() {
losingTeamId = nil
winningTeamId = nil
endDate = nil
removeCourt()
servingTeamId = nil
}
func teamWillBeWalkOut(_ team: TeamRegistration) {
resetMatch()
let previousScores = teamScores.filter({ $0.luckyLoser != nil })
try? DataStore.shared.teamScores.delete(contentOfs: previousScores)
if let existingTeamScore = teamScore(ofTeam: team) {
try? DataStore.shared.teamScores.delete(instance: existingTeamScore)
}
let teamScoreWalkout = TeamScore(match: id, team: team)
teamScoreWalkout.walkOut = 1
try? DataStore.shared.teamScores.addOrUpdate(instance: teamScoreWalkout)
}
func luckyLosers() -> [TeamRegistration] {
roundObject?.previousRound()?.losers() ?? []
}
func isWalkOutSpot(_ teamPosition: TeamPosition) -> Bool {
teamScore(teamPosition)?.walkOut == 1
}
func setLuckyLoser(team: TeamRegistration, teamPosition: TeamPosition) {
resetMatch()
let previousScores = teamScores.filter({ $0.luckyLoser != nil })
try? DataStore.shared.teamScores.delete(contentOfs: previousScores)
if let existingTeamScore = teamScore(ofTeam: team) {
try? DataStore.shared.teamScores.delete(instance: existingTeamScore)
}
let matchIndex = index
let position = matchIndex * 2 + teamPosition.rawValue
let teamScoreLuckyLoser = TeamScore(match: id, team: team)
teamScoreLuckyLoser.luckyLoser = position
try? DataStore.shared.teamScores.addOrUpdate(instance: teamScoreLuckyLoser)
}
func disableMatch() {
_toggleMatchDisableState(true)
}
func enableMatch() {
_toggleMatchDisableState(false)
}
private func _loserMatch() -> Match? {
let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: index)
return roundObject?.loserRounds().first?.getMatch(atMatchIndexInRound: indexInRound / 2)
}
func _toggleLoserMatchDisableState(_ state: Bool) {
guard let loserMatch = _loserMatch() else { return }
guard let next = _otherMatch() else { return }
loserMatch.byeState = true
if next.disabled {
loserMatch.byeState = false
}
loserMatch._toggleMatchDisableState(state, forward: true)
}
fileprivate func _otherMatch() -> Match? {
guard let round else { return nil }
guard index > 0 else { return nil }
let nextIndex = (index - 1) / 2
let topMatchIndex = (nextIndex * 2) + 1
let bottomMatchIndex = (nextIndex + 1) * 2
let isTopMatch = topMatchIndex + 1 == index
let lookingForIndex = isTopMatch ? topMatchIndex : bottomMatchIndex
return Store.main.filter(isIncluded: { $0.round == round && $0.index == lookingForIndex }).first
}
private func _forwardMatch(inRound round: Round) -> Match? {
guard let roundObjectNextRound = round.nextRound() else { return nil }
let nextIndex = (index - 1) / 2
return Store.main.filter(isIncluded: { $0.round == roundObjectNextRound.id && $0.index == nextIndex }).first
}
func _toggleForwardMatchDisableState(_ state: Bool) {
guard let roundObject else { return }
guard roundObject.parent != nil else { return }
guard let forwardMatch = _forwardMatch(inRound: roundObject) else { return }
guard let next = _otherMatch() else { return }
if next.disabled && byeState == false && next.byeState == false {
forwardMatch.byeState = false
forwardMatch._toggleMatchDisableState(state, forward: true)
} else if byeState && next.byeState {
print("don't disable forward match")
forwardMatch.byeState = false
forwardMatch._toggleMatchDisableState(false, forward: true)
} else {
forwardMatch.byeState = true
forwardMatch._toggleMatchDisableState(state, forward: true)
}
// if next.disabled == false {
// forwardMatch.byeState = state
// }
//
// if next.disabled == state {
// if next.byeState != byeState {
// //forwardMatch.byeState = state
// forwardMatch._toggleMatchDisableState(state)
// } else {
// forwardMatch._toggleByeState(state)
// }
// } else {
// }
// forwardMatch._toggleByeState(state)
}
func _toggleMatchDisableState(_ state: Bool, forward: Bool = false) {
//if disabled == state { return }
disabled = state
//byeState = false
//try? DataStore.shared.matches.addOrUpdate(instance: self)
_toggleLoserMatchDisableState(state)
if forward {
_toggleForwardMatchDisableState(state)
} else {
topPreviousRoundMatch()?._toggleMatchDisableState(state)
bottomPreviousRoundMatch()?._toggleMatchDisableState(state)
}
}
func next() -> Match? {
Store.main.filter(isIncluded: { $0.round == round && $0.index > index }).sorted(by: \.index).first
}
func getDuration() -> Int {
if let tournament = currentTournament() {
matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration)
} else {
matchFormat.getEstimatedDuration()
}
}
func roundTitle() -> String? {
if groupStage != nil { return "Poule" }
else if let roundObject { return roundObject.roundTitle() }
else { return nil }
}
func topPreviousRoundMatchIndex() -> Int {
index * 2 + 1
}
func bottomPreviousRoundMatchIndex() -> Int {
(index + 1) * 2
}
func topPreviousRoundMatch() -> Match? {
guard let roundObject else { return nil }
return Store.main.filter { match in
match.index == topPreviousRoundMatchIndex() && match.round != nil && match.round == roundObject.previousRound()?.id
}.sorted(by: \.index).first
}
func bottomPreviousRoundMatch() -> Match? {
guard let roundObject else { return nil }
return Store.main.filter { match in
match.index == bottomPreviousRoundMatchIndex() && match.round != nil && match.round == roundObject.previousRound()?.id
}.sorted(by: \.index).first
}
func upperBracketMatch(_ teamPosition: TeamPosition) -> Match? {
if teamPosition == .one {
return roundObject?.upperBracketTopMatch(ofMatchIndex: index)
} else {
return roundObject?.upperBracketBottomMatch(ofMatchIndex: index)
}
}
func previousMatch(_ teamPosition: TeamPosition) -> Match? {
if teamPosition == .one {
return topPreviousRoundMatch()
} else {
return bottomPreviousRoundMatch()
}
}
func upperMatches() -> [Match] {
guard let roundObject else { return [] }
return [roundObject.upperBracketTopMatch(ofMatchIndex: index), roundObject.upperBracketBottomMatch(ofMatchIndex: index)].compactMap({ $0 })
}
var computedOrder: Int {
guard let roundObject else { return index }
return roundObject.isLoserBracket() ? roundObject.index * 100 + indexInRound() : roundObject.index * 1000 + indexInRound()
}
func previousMatches() -> [Match] {
guard let roundObject else { return [] }
return Store.main.filter { match in
(match.index == topPreviousRoundMatchIndex() || match.index == bottomPreviousRoundMatchIndex())
&& match.round == roundObject.previousRound()?.id
}.sorted(by: \.index)
}
var matchFormat: MatchFormat {
get {
format ?? .defaultFormatForMatchType(.groupStage)
}
set {
format = newValue
}
}
func setWalkOut(_ teamPosition: TeamPosition) {
let teamScoreWalkout = teamScore(teamPosition) ?? TeamScore(match: id, team: team(teamPosition))
teamScoreWalkout.walkOut = 0
let teamScoreWinning = teamScore(teamPosition.otherTeam) ?? TeamScore(match: id, team: team(teamPosition.otherTeam))
teamScoreWinning.walkOut = nil
try? DataStore.shared.teamScores.addOrUpdate(contentOfs: [teamScoreWalkout, teamScoreWinning])
if endDate == nil {
endDate = Date()
}
winningTeamId = teamScoreWinning.teamRegistration
losingTeamId = teamScoreWalkout.teamRegistration
groupStageObject?.updateGroupStageState()
roundObject?.updateTournamentState()
}
func setScore(fromMatchDescriptor matchDescriptor: MatchDescriptor) {
updateScore(fromMatchDescriptor: matchDescriptor)
if endDate == nil {
endDate = Date()
}
winningTeamId = team(matchDescriptor.winner)?.id
losingTeamId = team(matchDescriptor.winner.otherTeam)?.id
groupStageObject?.updateGroupStageState()
roundObject?.updateTournamentState()
}
func updateScore(fromMatchDescriptor matchDescriptor: MatchDescriptor) {
let teamScoreOne = teamScore(.one) ?? TeamScore(match: id, team: team(.one))
teamScoreOne.score = matchDescriptor.teamOneScores.joined(separator: ",")
let teamScoreTwo = teamScore(.two) ?? TeamScore(match: id, team: team(.two))
teamScoreTwo.score = matchDescriptor.teamTwoScores.joined(separator: ",")
try? DataStore.shared.teamScores.addOrUpdate(contentOfs: [teamScoreOne, teamScoreTwo])
matchFormat = matchDescriptor.matchFormat
}
func validateMatch(fromStartDate: Date, toEndDate: Date, fieldSetup: MatchFieldSetup) {
if hasEnded() == false {
startDate = fromStartDate
switch fieldSetup {
case .random:
if let _courtIndex = availableCourts().randomElement() {
setCourt(_courtIndex)
}
case .field(let _courtIndex):
setCourt(_courtIndex)
}
} else {
startDate = fromStartDate
endDate = toEndDate
}
}
func courtName() -> String? {
guard let courtIndex else { return nil }
if let courtName = currentTournament()?.courtName(atIndex: courtIndex) {
return courtName
} else {
return Court.courtIndexedTitle(atIndex: courtIndex)
}
}
func courtCount() -> Int {
currentTournament()?.courtCount ?? 1
}
func courtIsAvailable(_ courtIndex: Int) -> Bool {
let courtUsed = currentTournament()?.courtUsed() ?? []
return courtUsed.contains(courtIndex) == false
// return Set(availableCourts().map { String($0) }).subtracting(Set(courtUsed))
}
func courtIsPreferred(_ courtIndex: Int) -> Bool {
false
}
func availableCourts() -> [Int] {
let courtUsed = currentTournament()?.courtUsed() ?? []
let availableCourts = Array(0..<courtCount())
return Array(Set(availableCourts.map { $0 }).subtracting(Set(courtUsed)))
}
func removeCourt() {
courtIndex = nil
}
func setCourt(_ courtIndex: Int) {
self.courtIndex = courtIndex
}
func canBeStarted(inMatches matches: [Match]) -> Bool {
let teams = teams()
guard teams.count == 2 else { return false }
guard hasEnded() == false else { return false }
guard hasStarted() == false else { return false }
return teams.allSatisfy({ $0.canPlay() && isTeamPlaying($0, inMatches: matches) == false })
}
func isTeamPlaying(_ team: TeamRegistration, inMatches matches: [Match]) -> Bool {
matches.filter({ $0.teams().contains(team) }).isEmpty == false
}
var computedStartDateForSorting: Date {
startDate ?? .distantFuture
}
var computedEndDateForSorting: Date {
endDate ?? .distantFuture
}
func isReady() -> Bool {
teams().count == 2
}
func isEmpty() -> Bool {
teams().isEmpty
}
func hasEnded() -> Bool {
endDate != nil || hasWalkoutTeam() || winningTeamId != nil
}
func isGroupStage() -> Bool {
groupStage != nil
}
func isBracket() -> Bool {
round != nil
}
func walkoutTeam() -> [TeamRegistration] {
scores().filter({ $0.walkOut != nil }).compactMap { $0.team }
}
func hasWalkoutTeam() -> Bool {
walkoutTeam().isEmpty == false
}
func currentTournament() -> Tournament? {
groupStageObject?.tournamentObject() ?? roundObject?.tournamentObject()
}
func tournamentId() -> String? {
groupStageObject?.tournament ?? roundObject?.tournament
}
func scores() -> [TeamScore] {
Store.main.filter(isIncluded: { $0.match == id })
}
func teams() -> [TeamRegistration] {
if groupStage != nil {
return [groupStageProjectedTeam(.one), groupStageProjectedTeam(.two)].compactMap { $0 }
}
return [roundProjectedTeam(.one), roundProjectedTeam(.two)].compactMap { $0 }
}
func scoreDifference(_ teamPosition: Int) -> (set: Int, game: Int)? {
guard let teamScoreTeam = teamScore(.one), let teamScoreOtherTeam = teamScore(.two) else { return nil }
var reverseValue = 1
if teamPosition == team(.two)?.groupStagePosition {
reverseValue = -1
}
let endedSetsOne = teamScoreTeam.score?.components(separatedBy: ",").compactMap({ Int($0) }) ?? matchFormat.defaultWalkOutScore(teamScoreTeam.isWalkOut())
let endedSetsTwo = teamScoreOtherTeam.score?.components(separatedBy: ",").compactMap({ Int($0) }) ?? matchFormat.defaultWalkOutScore(teamScoreOtherTeam.isWalkOut())
var setDifference : Int = 0
let zip = zip(endedSetsOne, endedSetsTwo)
if matchFormat.setsToWin == 1 {
setDifference = endedSetsOne[0] - endedSetsTwo[0]
} else {
setDifference = zip.filter { $0 > $1 }.count - zip.filter { $1 > $0 }.count
}
let gameDifference = zip.map { ($0, $1) }.map { $0.0 - $0.1 }.reduce(0,+)
return (setDifference * reverseValue, gameDifference * reverseValue)
}
func groupStageProjectedTeam(_ team: TeamPosition) -> TeamRegistration? {
guard let groupStageObject else { return nil }
return groupStageObject.team(teamPosition: team, inMatchIndex: index)
}
func roundProjectedTeam(_ team: TeamPosition) -> TeamRegistration? {
guard let roundObject else { return nil }
return roundObject.roundProjectedTeam(team, inMatch: self)
}
func teamWon(_ team: TeamRegistration?) -> Bool {
guard let winningTeamId else { return false }
return winningTeamId == team?.id
}
func team(_ team: TeamPosition) -> TeamRegistration? {
if groupStage != nil {
switch team {
case .one:
return groupStageProjectedTeam(.one)
case .two:
return groupStageProjectedTeam(.two)
}
} else {
switch team {
case .one:
return roundProjectedTeam(.one)
case .two:
return roundProjectedTeam(.two)
}
}
}
func teamNames(_ team: TeamRegistration?) -> [String]? {
team?.players().map { $0.playerLabel() }
}
func teamWalkOut(_ team: TeamRegistration?) -> Bool {
teamScore(ofTeam: team)?.isWalkOut() == true
}
func teamScore(_ team: TeamPosition) -> TeamScore? {
teamScore(ofTeam: self.team(team))
}
func teamScore(ofTeam team: TeamRegistration?) -> TeamScore? {
scores().first(where: { $0.teamRegistration == team?.id })
}
func isRunning() -> Bool { // at least a match has started
hasStarted() && hasEnded() == false
}
func hasStarted() -> Bool { // meaning at least one match is over
if let startDate {
return startDate.timeIntervalSinceNow < 0
}
if hasEnded() {
return true
}
return false
//todo scores
// if let score {
// return score.hasEnded == false && score.sets.isEmpty == false
// } else {
// return false
// }
}
var roundObject: Round? {
guard let round else { return nil }
return Store.main.findById(round)
}
var groupStageObject: GroupStage? {
guard let groupStage else { return nil }
return Store.main.findById(groupStage)
}
var isLoserBracket: Bool {
roundObject?.parent != nil
}
var teamScores: [TeamScore] {
Store.main.filter { $0.match == self.id }
}
override func deleteDependencies() throws {
try Store.main.deleteDependencies(items: self.teamScores)
}
enum CodingKeys: String, CodingKey {
case _id = "id"
case _round = "round"
case _groupStage = "groupStage"
case _startDate = "startDate"
case _endDate = "endDate"
case _index = "index"
case _format = "format"
// case _court = "court"
case _courtIndex = "courtIndex"
case _servingTeamId = "servingTeamId"
case _winningTeamId = "winningTeamId"
case _losingTeamId = "losingTeamId"
// case _broadcasted = "broadcasted"
// case _name = "name"
// case _order = "order"
case _disabled = "disabled"
}
}
enum MatchDateSetup: Hashable, Identifiable {
case inMinutes(Int)
case now
case customDate
var id: Int { hashValue }
}
enum MatchFieldSetup: Hashable, Identifiable {
case random
// case firstAvailable
case field(Int)
var id: Int { hashValue }
}