From bfc5e56fc9aa6ba073fd180633a3853d0967ae45 Mon Sep 17 00:00:00 2001 From: Patrick Fairbank Date: Wed, 3 Aug 2022 20:53:16 -0700 Subject: [PATCH] Refactor playoff bracket logic to more easily support alternative formats. --- bracket/bracket.go | 181 ++++++++ bracket/bracket_test.go | 171 ++++++++ bracket/matchup.go | 228 +++++++++++ bracket/single_elimination.go | 128 ++++++ .../single_elimination_test.go | 387 +++++++----------- bracket/test_helpers.go | 36 ++ field/arena.go | 34 ++ model/alliance.go | 33 ++ model/alliance_test.go | 13 + model/match.go | 4 +- tournament/elimination_schedule.go | 262 ------------ web/alliance_selection.go | 14 +- web/match_play.go | 31 +- web/match_play_test.go | 1 + web/match_review_test.go | 2 + 15 files changed, 995 insertions(+), 530 deletions(-) create mode 100644 bracket/bracket.go create mode 100644 bracket/bracket_test.go create mode 100644 bracket/matchup.go create mode 100644 bracket/single_elimination.go rename tournament/elimination_schedule_test.go => bracket/single_elimination_test.go (59%) create mode 100644 bracket/test_helpers.go delete mode 100644 tournament/elimination_schedule.go diff --git a/bracket/bracket.go b/bracket/bracket.go new file mode 100644 index 0000000..3c3a6e5 --- /dev/null +++ b/bracket/bracket.go @@ -0,0 +1,181 @@ +// Copyright 2022 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) +// +// Model and logic encapsulating a playoff elimination bracket. + +package bracket + +import ( + "fmt" + "github.com/Team254/cheesy-arena-lite/model" + "time" +) + +type Bracket struct { + FinalsMatchup *Matchup +} + +const ElimMatchSpacingSec = 600 + +// Creates an unpopulated bracket with a format that is defined by the given matchup templates and number of alliances. +func newBracket(matchupTemplates []matchupTemplate, numAlliances int) (*Bracket, error) { + // Create a map of matchup templates by key for easy lookup while creating the bracket. + matchupTemplateMap := make(map[matchupKey]matchupTemplate, len(matchupTemplates)) + for _, matchupTemplate := range matchupTemplates { + matchupTemplateMap[matchupTemplate.matchupKey] = matchupTemplate + } + + // Recursively build the bracket, starting with the finals matchup. + finalsMatchup, _, err := createMatchupTree(newMatchupKey(1, 1), matchupTemplateMap, numAlliances) + if err != nil { + return nil, err + } + + return &Bracket{FinalsMatchup: finalsMatchup}, nil +} + +// Recursive helper method to create the current matchup node and all of its children. +func createMatchupTree( + matchupKey matchupKey, matchupTemplateMap map[matchupKey]matchupTemplate, numAlliances int, +) (*Matchup, int, error) { + matchupTemplate, ok := matchupTemplateMap[matchupKey] + if !ok { + return nil, 0, fmt.Errorf("could not find template for matchup %+v in the list of templates", matchupKey) + } + + redAllianceIdFromSelection := matchupTemplate.redAllianceSource.allianceId + blueAllianceIdFromSelection := matchupTemplate.blueAllianceSource.allianceId + if redAllianceIdFromSelection > 0 || blueAllianceIdFromSelection > 0 { + // This is a leaf node in the matchup tree; the alliances will come from the alliance selection. + if redAllianceIdFromSelection == 0 || blueAllianceIdFromSelection == 0 { + return nil, 0, fmt.Errorf("both alliances must be populated either from selection or a lower round") + } + + // Zero out alliance IDs that don't exist at this tournament to signal that this matchup doesn't need to be + // played. + if redAllianceIdFromSelection > numAlliances { + redAllianceIdFromSelection = 0 + } + if blueAllianceIdFromSelection > numAlliances { + blueAllianceIdFromSelection = 0 + } + + if redAllianceIdFromSelection > 0 && blueAllianceIdFromSelection > 0 { + // This is a real matchup that will be played out. + return &Matchup{ + matchupTemplate: matchupTemplate, + RedAllianceId: redAllianceIdFromSelection, + BlueAllianceId: blueAllianceIdFromSelection, + }, 0, nil + } + if redAllianceIdFromSelection == 0 && blueAllianceIdFromSelection == 0 { + // This matchup should be pruned from the bracket since neither alliance has a valid source; this tournament + // is too small for this matchup to be played. + return nil, 0, nil + } + if redAllianceIdFromSelection > 0 { + // The red alliance has a bye. + return nil, redAllianceIdFromSelection, nil + } else { + // The blue alliance has a bye. + return nil, blueAllianceIdFromSelection, nil + } + } + + // Recurse to determine the lower-round red and blue matchups that will feed into this one, or the alliances that + // have a bye to this round. + redAllianceSourceMatchup, redByeAllianceId, err := createMatchupTree( + matchupTemplate.redAllianceSource.matchupKey, matchupTemplateMap, numAlliances, + ) + if err != nil { + return nil, 0, err + } + blueAllianceSourceMatchup, blueByeAllianceId, err := createMatchupTree( + matchupTemplate.blueAllianceSource.matchupKey, matchupTemplateMap, numAlliances, + ) + if err != nil { + return nil, 0, err + } + + if redAllianceSourceMatchup == nil && redByeAllianceId == 0 && + blueAllianceSourceMatchup == nil && blueByeAllianceId == 0 { + // This matchup should be pruned from the bracket since neither alliance has a valid source; this tournament is + // too small for this matchup to be played. + return nil, 0, nil + } + if redByeAllianceId > 0 && blueAllianceSourceMatchup == nil && blueByeAllianceId == 0 { + // The red alliance has a bye. + return nil, redByeAllianceId, nil + } + if blueByeAllianceId > 0 && redAllianceSourceMatchup == nil && redByeAllianceId == 0 { + // The blue alliance has a bye. + return nil, blueByeAllianceId, nil + } + + // This is a real matchup that will be played out. + return &Matchup{ + matchupTemplate: matchupTemplate, + RedAllianceId: redByeAllianceId, + BlueAllianceId: blueByeAllianceId, + RedAllianceSourceMatchup: redAllianceSourceMatchup, + BlueAllianceSourceMatchup: blueAllianceSourceMatchup, + }, 0, nil +} + +// Returns the winning alliance ID of the entire bracket, or 0 if it is not yet known. +func (bracket *Bracket) Winner() int { + return bracket.FinalsMatchup.winner() +} + +// Returns the finalist alliance ID of the entire bracket, or 0 if it is not yet known. +func (bracket *Bracket) Finalist() int { + return bracket.FinalsMatchup.loser() +} + +// Returns true if the bracket has been won, and false if it is still to be determined. +func (bracket *Bracket) IsComplete() bool { + return bracket.FinalsMatchup.isComplete() +} + +// Traverses the bracket to update the state of each matchup based on match results, counting wins and creating or +// deleting matches as required. +func (bracket *Bracket) Update(database *model.Database, startTime *time.Time) error { + if err := bracket.FinalsMatchup.update(database); err != nil { + return err + } + + if startTime != nil { + // Update the scheduled time for all matches that have yet to be run. + matches, err := database.GetMatchesByType("elimination") + if err != nil { + return err + } + matchIndex := 0 + for _, match := range matches { + if match.IsComplete() { + continue + } + match.Time = startTime.Add(time.Duration(matchIndex*ElimMatchSpacingSec) * time.Second) + if err = database.UpdateMatch(&match); err != nil { + return err + } + matchIndex++ + } + } + + return nil +} + +// Prints out each matchup within the bracket in level order, backwards from finals to earlier rounds, for debugging. +func (bracket *Bracket) print() { + matchupQueue := []*Matchup{bracket.FinalsMatchup} + for len(matchupQueue) > 0 { + matchup := matchupQueue[0] + fmt.Printf("%+v\n\n", matchup) + matchupQueue = matchupQueue[1:] + if matchup != nil { + matchupQueue = append(matchupQueue, matchup.RedAllianceSourceMatchup) + matchupQueue = append(matchupQueue, matchup.BlueAllianceSourceMatchup) + } + } +} diff --git a/bracket/bracket_test.go b/bracket/bracket_test.go new file mode 100644 index 0000000..0b95416 --- /dev/null +++ b/bracket/bracket_test.go @@ -0,0 +1,171 @@ +// Copyright 2022 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) + +package bracket + +import ( + "github.com/Team254/cheesy-arena-lite/model" + "github.com/Team254/cheesy-arena-lite/tournament" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestNewBracketErrors(t *testing.T) { + _, err := newBracket([]matchupTemplate{}, 8) + if assert.NotNil(t, err) { + assert.Equal(t, "could not find template for matchup {round:1 group:1} in the list of templates", err.Error()) + } + + matchTemplate := matchupTemplate{ + matchupKey: newMatchupKey(1, 1), + redAllianceSource: allianceSource{allianceId: 1}, + blueAllianceSource: newMatchupAllianceSource(2, 2), + } + _, err = newBracket([]matchupTemplate{matchTemplate}, 8) + if assert.NotNil(t, err) { + assert.Equal(t, "both alliances must be populated either from selection or a lower round", err.Error()) + } +} + +func TestNewBracketInverseSeeding(t *testing.T) { + database := setupTestDb(t) + matchupTemplates := []matchupTemplate{ + { + matchupKey: newMatchupKey(1, 1), + displayNameFormat: "F-${instance}", + numWinsToAdvance: 2, + redAllianceSource: newMatchupAllianceSource(2, 1), + blueAllianceSource: newMatchupAllianceSource(2, 2), + }, + { + matchupKey: newMatchupKey(2, 1), + displayNameFormat: "SF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: newMatchupAllianceSource(4, 2), + blueAllianceSource: newMatchupAllianceSource(4, 1), + }, + { + matchupKey: newMatchupKey(2, 2), + displayNameFormat: "SF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: allianceSource{allianceId: 3}, + blueAllianceSource: allianceSource{allianceId: 2}, + }, + { + matchupKey: newMatchupKey(4, 1), + displayNameFormat: "SF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: allianceSource{allianceId: 8}, + blueAllianceSource: allianceSource{allianceId: 1}, + }, + { + matchupKey: newMatchupKey(4, 2), + displayNameFormat: "SF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: allianceSource{allianceId: 5}, + blueAllianceSource: allianceSource{allianceId: 4}, + }, + } + + tournament.CreateTestAlliances(database, 2) + bracket, err := newBracket(matchupTemplates, 2) + assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err := database.GetMatchesByType("elimination") + assert.Nil(t, err) + if assert.Equal(t, 2, len(matches)) { + assertMatch(t, matches[0], "F-1", 1, 2) + assertMatch(t, matches[1], "F-2", 1, 2) + } +} + +func TestBracketUpdateTiming(t *testing.T) { + database := setupTestDb(t) + + tournament.CreateTestAlliances(database, 4) + bracket, err := NewSingleEliminationBracket(4) + assert.Nil(t, err) + startTime := time.Unix(1000, 0) + assert.Nil(t, bracket.Update(database, &startTime)) + matches, err := database.GetMatchesByType("elimination") + assert.Nil(t, err) + if assert.Equal(t, 4, len(matches)) { + assert.Equal(t, int64(1000), matches[0].Time.Unix()) + assert.Equal(t, int64(1600), matches[1].Time.Unix()) + assert.Equal(t, int64(2200), matches[2].Time.Unix()) + assert.Equal(t, int64(2800), matches[3].Time.Unix()) + } + scoreMatch(database, "SF1-1", model.RedWonMatch) + scoreMatch(database, "SF1-2", model.BlueWonMatch) + startTime = time.Unix(5000, 0) + assert.Nil(t, bracket.Update(database, &startTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + if assert.Equal(t, 5, len(matches)) { + assert.Equal(t, int64(1000), matches[0].Time.Unix()) + assert.Equal(t, int64(5000), matches[1].Time.Unix()) + assert.Equal(t, int64(2200), matches[2].Time.Unix()) + assert.Equal(t, int64(5600), matches[3].Time.Unix()) + assert.Equal(t, int64(6200), matches[4].Time.Unix()) + } +} + +func TestBracketUpdateTeamPositions(t *testing.T) { + database := setupTestDb(t) + + tournament.CreateTestAlliances(database, 4) + bracket, err := NewSingleEliminationBracket(4) + assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, _ := database.GetMatchesByType("elimination") + match1 := matches[0] + match2 := matches[1] + assert.Equal(t, 102, match1.Red1) + assert.Equal(t, 101, match1.Red2) + assert.Equal(t, 103, match1.Red3) + assert.Equal(t, 302, match2.Blue1) + assert.Equal(t, 301, match2.Blue2) + assert.Equal(t, 303, match2.Blue3) + + // Shuffle the team positions and check that the subsequent matches in the same round have the same ones. + match1.Red1, match1.Red2 = match1.Red2, 104 + match2.Blue1, match2.Blue3 = 305, match2.Blue1 + database.UpdateMatch(&match1) + database.UpdateMatch(&match2) + scoreMatch(database, "SF1-1", model.RedWonMatch) + scoreMatch(database, "SF2-1", model.BlueWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, _ = database.GetMatchesByType("elimination") + if assert.Equal(t, 4, len(matches)) { + assert.Equal(t, match1.Red1, matches[0].Red1) + assert.Equal(t, match1.Red2, matches[0].Red2) + assert.Equal(t, match1.Red3, matches[0].Red3) + assert.Equal(t, match1.Red1, matches[2].Red1) + assert.Equal(t, match1.Red2, matches[2].Red2) + assert.Equal(t, match1.Red3, matches[2].Red3) + + assert.Equal(t, match2.Blue1, matches[1].Blue1) + assert.Equal(t, match2.Blue2, matches[1].Blue2) + assert.Equal(t, match2.Blue3, matches[1].Blue3) + assert.Equal(t, match2.Blue1, matches[3].Blue1) + assert.Equal(t, match2.Blue2, matches[3].Blue2) + assert.Equal(t, match2.Blue3, matches[3].Blue3) + } + + // Advance them to the finals and verify that the team position updates have been propagated. + scoreMatch(database, "SF1-2", model.RedWonMatch) + scoreMatch(database, "SF2-2", model.BlueWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, _ = database.GetMatchesByType("elimination") + if assert.Equal(t, 6, len(matches)) { + for i := 4; i < 6; i++ { + assert.Equal(t, match1.Red1, matches[i].Red1) + assert.Equal(t, match1.Red2, matches[i].Red2) + assert.Equal(t, match1.Red3, matches[i].Red3) + assert.Equal(t, match2.Blue1, matches[i].Blue1) + assert.Equal(t, match2.Blue2, matches[i].Blue2) + assert.Equal(t, match2.Blue3, matches[i].Blue3) + } + } +} diff --git a/bracket/matchup.go b/bracket/matchup.go new file mode 100644 index 0000000..5cda810 --- /dev/null +++ b/bracket/matchup.go @@ -0,0 +1,228 @@ +// Copyright 2022 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) +// +// Models and logic encapsulating a group of one or more matches between the same two alliances at a given point in a +// playoff tournament. + +package bracket + +import ( + "github.com/Team254/cheesy-arena-lite/model" + "strconv" + "strings" +) + +// Conveys how a given alliance should be populated -- either directly from alliance selection or based on the results +// of a prior matchup. +type allianceSource struct { + allianceId int + matchupKey matchupKey +} + +// Key for uniquely identifying a matchup. Round IDs are arbitrary and in descending order with "1" always representing +// the playoff finals. Group IDs are 1-indexed within a round and increasing in order of play. +type matchupKey struct { + round int + group int +} + +// Conveys the complete generic information about a matchup required to construct it. In aggregate, the full list of +// match templates describing a bracket format can be used to construct an empty playoff bracket for a given number of +// alliances. +type matchupTemplate struct { + matchupKey + displayNameFormat string + numWinsToAdvance int + redAllianceSource allianceSource + blueAllianceSource allianceSource +} + +// Encapsulates the format and state of a group of one or more matches between the same two alliances at a given point +// in a playoff tournament. +type Matchup struct { + matchupTemplate + RedAllianceSourceMatchup *Matchup + BlueAllianceSourceMatchup *Matchup + RedAllianceId int + BlueAllianceId int + RedAllianceWins int + BlueAllianceWins int +} + +// Convenience method to quickly create an alliance source that points to a different matchup. +func newMatchupAllianceSource(round, group int) allianceSource { + return allianceSource{matchupKey: newMatchupKey(round, group)} +} + +// Convenience method to quickly create a matchup key. +func newMatchupKey(round, group int) matchupKey { + return matchupKey{round: round, group: group} +} + +// Returns the display name for a specific match within a matchup. +func (matchupTemplate *matchupTemplate) displayName(instance int) string { + displayName := matchupTemplate.displayNameFormat + displayName = strings.Replace(displayName, "${group}", strconv.Itoa(matchupTemplate.group), -1) + displayName = strings.Replace(displayName, "${instance}", strconv.Itoa(instance), -1) + return displayName +} + +// Returns the winning alliance ID of the matchup, or 0 if it is not yet known. +func (matchup *Matchup) winner() int { + if matchup.RedAllianceWins >= matchup.numWinsToAdvance { + return matchup.RedAllianceId + } + if matchup.BlueAllianceWins >= matchup.numWinsToAdvance { + return matchup.BlueAllianceId + } + return 0 +} + +// Returns the losing alliance ID of the matchup, or 0 if it is not yet known. +func (matchup *Matchup) loser() int { + if matchup.RedAllianceWins >= matchup.numWinsToAdvance { + return matchup.BlueAllianceId + } + if matchup.BlueAllianceWins >= matchup.numWinsToAdvance { + return matchup.RedAllianceId + } + return 0 +} + +// Returns true if the matchup has been won, and false if it is still to be determined. +func (matchup *Matchup) isComplete() bool { + return matchup.winner() > 0 +} + +// Recursively traverses the matchup tree to update the state of this matchup and all of its children based on match +// results, counting wins and creating or deleting matches as required. +func (matchup *Matchup) update(database *model.Database) error { + if matchup.RedAllianceSourceMatchup != nil { + if err := matchup.RedAllianceSourceMatchup.update(database); err != nil { + return err + } + } + if matchup.BlueAllianceSourceMatchup != nil { + if err := matchup.BlueAllianceSourceMatchup.update(database); err != nil { + return err + } + } + + // Populate the alliance IDs from the lower matchups (or with a zero value if they are not yet complete). + if matchup.RedAllianceSourceMatchup != nil { + matchup.RedAllianceId = matchup.RedAllianceSourceMatchup.winner() + } + if matchup.BlueAllianceSourceMatchup != nil { + matchup.BlueAllianceId = matchup.BlueAllianceSourceMatchup.winner() + } + + // Bail if we do not yet know both alliances. + if matchup.RedAllianceId == 0 || matchup.BlueAllianceId == 0 { + // Ensure the current state is reset; it may have previously been populated if a match result was edited. + matchup.RedAllianceWins = 0 + matchup.BlueAllianceWins = 0 + return nil + } + + // Create, update, and/or delete unplayed matches as required. + redAlliance, err := database.GetAllianceById(matchup.RedAllianceId) + if err != nil { + return err + } + blueAlliance, err := database.GetAllianceById(matchup.BlueAllianceId) + if err != nil { + return err + } + matches, err := database.GetMatchesByElimRoundGroup(matchup.round, matchup.group) + if err != nil { + return err + } + matchup.RedAllianceWins = 0 + matchup.BlueAllianceWins = 0 + var unplayedMatches []model.Match + for _, match := range matches { + if !match.IsComplete() { + // Update the teams in the match if they are not yet set or are incorrect. + changed := false + if match.Red1 != redAlliance.Lineup[0] || match.Red2 != redAlliance.Lineup[1] || + match.Red3 != redAlliance.Lineup[2] { + positionRedTeams(&match, redAlliance) + match.ElimRedAlliance = redAlliance.Id + changed = true + if err = database.UpdateMatch(&match); err != nil { + return err + } + } + if match.Blue1 != blueAlliance.Lineup[0] || match.Blue2 != blueAlliance.Lineup[1] || + match.Blue3 != blueAlliance.Lineup[2] { + positionBlueTeams(&match, blueAlliance) + match.ElimBlueAlliance = blueAlliance.Id + changed = true + } + if changed { + if err = database.UpdateMatch(&match); err != nil { + return err + } + } + + unplayedMatches = append(unplayedMatches, match) + continue + } + + // Check who won. + if match.Status == model.RedWonMatch { + matchup.RedAllianceWins++ + } else if match.Status == model.BlueWonMatch { + matchup.BlueAllianceWins++ + } + } + + maxWins := matchup.RedAllianceWins + if matchup.BlueAllianceWins > maxWins { + maxWins = matchup.BlueAllianceWins + } + numUnplayedMatchesNeeded := matchup.numWinsToAdvance - maxWins + if len(unplayedMatches) > numUnplayedMatchesNeeded { + // Delete any superfluous matches off the end of the list. + for i := 0; i < len(unplayedMatches)-numUnplayedMatchesNeeded; i++ { + if err = database.DeleteMatch(unplayedMatches[len(unplayedMatches)-i-1].Id); err != nil { + return err + } + } + } else if len(unplayedMatches) < numUnplayedMatchesNeeded { + // Create initial set of matches or any additional required matches due to tie matches or ties in the round. + for i := 0; i < numUnplayedMatchesNeeded-len(unplayedMatches); i++ { + instance := len(matches) + i + 1 + match := model.Match{ + Type: "elimination", + DisplayName: matchup.displayName(instance), + ElimRound: matchup.round, + ElimGroup: matchup.group, + ElimInstance: instance, + ElimRedAlliance: redAlliance.Id, + ElimBlueAlliance: blueAlliance.Id, + } + positionRedTeams(&match, redAlliance) + positionBlueTeams(&match, blueAlliance) + if err = database.CreateMatch(&match); err != nil { + return err + } + } + } + + return nil +} + +// Assigns the lineup from the alliance into the red team slots for the match. +func positionRedTeams(match *model.Match, alliance *model.Alliance) { + match.Red1 = alliance.Lineup[0] + match.Red2 = alliance.Lineup[1] + match.Red3 = alliance.Lineup[2] +} + +// Assigns the lineup from the alliance into the blue team slots for the match. +func positionBlueTeams(match *model.Match, alliance *model.Alliance) { + match.Blue1 = alliance.Lineup[0] + match.Blue2 = alliance.Lineup[1] + match.Blue3 = alliance.Lineup[2] +} diff --git a/bracket/single_elimination.go b/bracket/single_elimination.go new file mode 100644 index 0000000..1ed0a8b --- /dev/null +++ b/bracket/single_elimination.go @@ -0,0 +1,128 @@ +// Copyright 2022 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) +// +// Defines the tournament structure for a single-elimination, best-of-three bracket. + +package bracket + +import "fmt" + +// Creates an unpopulated single-elimination bracket containing only the required matchups for the given number of +// alliances. +func NewSingleEliminationBracket(numAlliances int) (*Bracket, error) { + if numAlliances < 2 { + return nil, fmt.Errorf("Must have at least 2 alliances") + } + if numAlliances > 16 { + return nil, fmt.Errorf("Must have at most 16 alliances") + } + return newBracket(singleEliminationBracketMatchupTemplates, numAlliances) +} + +var singleEliminationBracketMatchupTemplates = []matchupTemplate{ + { + matchupKey: newMatchupKey(1, 1), + displayNameFormat: "F-${instance}", + numWinsToAdvance: 2, + redAllianceSource: newMatchupAllianceSource(2, 1), + blueAllianceSource: newMatchupAllianceSource(2, 2), + }, + { + matchupKey: newMatchupKey(2, 1), + displayNameFormat: "SF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: newMatchupAllianceSource(4, 1), + blueAllianceSource: newMatchupAllianceSource(4, 2), + }, + { + matchupKey: newMatchupKey(2, 2), + displayNameFormat: "SF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: newMatchupAllianceSource(4, 3), + blueAllianceSource: newMatchupAllianceSource(4, 4), + }, + { + matchupKey: newMatchupKey(4, 1), + displayNameFormat: "QF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: newMatchupAllianceSource(8, 1), + blueAllianceSource: newMatchupAllianceSource(8, 2), + }, + { + matchupKey: newMatchupKey(4, 2), + displayNameFormat: "QF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: newMatchupAllianceSource(8, 3), + blueAllianceSource: newMatchupAllianceSource(8, 4), + }, + { + matchupKey: newMatchupKey(4, 3), + displayNameFormat: "QF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: newMatchupAllianceSource(8, 5), + blueAllianceSource: newMatchupAllianceSource(8, 6), + }, + { + matchupKey: newMatchupKey(4, 4), + displayNameFormat: "QF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: newMatchupAllianceSource(8, 7), + blueAllianceSource: newMatchupAllianceSource(8, 8), + }, + { + matchupKey: newMatchupKey(8, 1), + displayNameFormat: "EF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: allianceSource{allianceId: 1}, + blueAllianceSource: allianceSource{allianceId: 16}, + }, + { + matchupKey: newMatchupKey(8, 2), + displayNameFormat: "EF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: allianceSource{allianceId: 8}, + blueAllianceSource: allianceSource{allianceId: 9}, + }, + { + matchupKey: newMatchupKey(8, 3), + displayNameFormat: "EF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: allianceSource{allianceId: 4}, + blueAllianceSource: allianceSource{allianceId: 13}, + }, + { + matchupKey: newMatchupKey(8, 4), + displayNameFormat: "EF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: allianceSource{allianceId: 5}, + blueAllianceSource: allianceSource{allianceId: 12}, + }, + { + matchupKey: newMatchupKey(8, 5), + displayNameFormat: "EF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: allianceSource{allianceId: 2}, + blueAllianceSource: allianceSource{allianceId: 15}, + }, + { + matchupKey: newMatchupKey(8, 6), + displayNameFormat: "EF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: allianceSource{allianceId: 7}, + blueAllianceSource: allianceSource{allianceId: 10}, + }, + { + matchupKey: newMatchupKey(8, 7), + displayNameFormat: "EF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: allianceSource{allianceId: 3}, + blueAllianceSource: allianceSource{allianceId: 14}, + }, + { + matchupKey: newMatchupKey(8, 8), + displayNameFormat: "EF${group}-${instance}", + numWinsToAdvance: 2, + redAllianceSource: allianceSource{allianceId: 6}, + blueAllianceSource: allianceSource{allianceId: 11}, + }, +} diff --git a/tournament/elimination_schedule_test.go b/bracket/single_elimination_test.go similarity index 59% rename from tournament/elimination_schedule_test.go rename to bracket/single_elimination_test.go index 0c5e175..9bf9964 100644 --- a/tournament/elimination_schedule_test.go +++ b/bracket/single_elimination_test.go @@ -1,21 +1,25 @@ -// Copyright 2014 Team 254. All Rights Reserved. +// Copyright 2022 Team 254. All Rights Reserved. // Author: pat@patfairbank.com (Patrick Fairbank) -package tournament +package bracket import ( "github.com/Team254/cheesy-arena-lite/model" + "github.com/Team254/cheesy-arena-lite/tournament" "github.com/stretchr/testify/assert" "testing" "time" ) -func TestEliminationScheduleInitial(t *testing.T) { +var dummyStartTime = time.Unix(0, 0) + +func TestSingleEliminationInitial(t *testing.T) { database := setupTestDb(t) - CreateTestAlliances(database, 2) - _, err := UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 2) + bracket, err := NewSingleEliminationBracket(2) assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err := database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 2, len(matches)) { @@ -25,9 +29,10 @@ func TestEliminationScheduleInitial(t *testing.T) { database.TruncateAlliances() database.TruncateMatches() - CreateTestAlliances(database, 3) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 3) + bracket, err = NewSingleEliminationBracket(3) assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err = database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 2, len(matches)) { @@ -37,9 +42,10 @@ func TestEliminationScheduleInitial(t *testing.T) { database.TruncateAlliances() database.TruncateMatches() - CreateTestAlliances(database, 4) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 4) + bracket, err = NewSingleEliminationBracket(4) assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err = database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 4, len(matches)) { @@ -51,9 +57,10 @@ func TestEliminationScheduleInitial(t *testing.T) { database.TruncateAlliances() database.TruncateMatches() - CreateTestAlliances(database, 5) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 5) + bracket, err = NewSingleEliminationBracket(5) assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err = database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 4, len(matches)) { @@ -65,9 +72,10 @@ func TestEliminationScheduleInitial(t *testing.T) { database.TruncateAlliances() database.TruncateMatches() - CreateTestAlliances(database, 6) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 6) + bracket, err = NewSingleEliminationBracket(6) assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err = database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 4, len(matches)) { @@ -79,9 +87,10 @@ func TestEliminationScheduleInitial(t *testing.T) { database.TruncateAlliances() database.TruncateMatches() - CreateTestAlliances(database, 7) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 7) + bracket, err = NewSingleEliminationBracket(7) assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err = database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 6, len(matches)) { @@ -95,9 +104,10 @@ func TestEliminationScheduleInitial(t *testing.T) { database.TruncateAlliances() database.TruncateMatches() - CreateTestAlliances(database, 8) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 8) + bracket, err = NewSingleEliminationBracket(8) assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err = database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 8, len(matches)) { @@ -113,9 +123,10 @@ func TestEliminationScheduleInitial(t *testing.T) { database.TruncateAlliances() database.TruncateMatches() - CreateTestAlliances(database, 9) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 9) + bracket, err = NewSingleEliminationBracket(9) assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err = database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 8, len(matches)) { @@ -131,9 +142,10 @@ func TestEliminationScheduleInitial(t *testing.T) { database.TruncateAlliances() database.TruncateMatches() - CreateTestAlliances(database, 10) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 10) + bracket, err = NewSingleEliminationBracket(10) assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err = database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 8, len(matches)) { @@ -149,9 +161,10 @@ func TestEliminationScheduleInitial(t *testing.T) { database.TruncateAlliances() database.TruncateMatches() - CreateTestAlliances(database, 11) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 11) + bracket, err = NewSingleEliminationBracket(11) assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err = database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 8, len(matches)) { @@ -167,9 +180,10 @@ func TestEliminationScheduleInitial(t *testing.T) { database.TruncateAlliances() database.TruncateMatches() - CreateTestAlliances(database, 12) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 12) + bracket, err = NewSingleEliminationBracket(12) assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err = database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 8, len(matches)) { @@ -185,9 +199,10 @@ func TestEliminationScheduleInitial(t *testing.T) { database.TruncateAlliances() database.TruncateMatches() - CreateTestAlliances(database, 13) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 13) + bracket, err = NewSingleEliminationBracket(13) assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err = database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 10, len(matches)) { @@ -205,9 +220,10 @@ func TestEliminationScheduleInitial(t *testing.T) { database.TruncateAlliances() database.TruncateMatches() - CreateTestAlliances(database, 14) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 14) + bracket, err = NewSingleEliminationBracket(14) assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err = database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 12, len(matches)) { @@ -227,9 +243,10 @@ func TestEliminationScheduleInitial(t *testing.T) { database.TruncateAlliances() database.TruncateMatches() - CreateTestAlliances(database, 15) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 15) + bracket, err = NewSingleEliminationBracket(15) assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err = database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 14, len(matches)) { @@ -251,9 +268,10 @@ func TestEliminationScheduleInitial(t *testing.T) { database.TruncateAlliances() database.TruncateMatches() - CreateTestAlliances(database, 16) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 16) + bracket, err = NewSingleEliminationBracket(16) assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err = database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 16, len(matches)) { @@ -278,34 +296,29 @@ func TestEliminationScheduleInitial(t *testing.T) { database.TruncateMatches() } -func TestEliminationScheduleErrors(t *testing.T) { - database := setupTestDb(t) - - CreateTestAlliances(database, 1) - _, err := UpdateEliminationSchedule(database, time.Unix(0, 0)) +func TestSingleEliminationErrors(t *testing.T) { + _, err := NewSingleEliminationBracket(1) if assert.NotNil(t, err) { assert.Equal(t, "Must have at least 2 alliances", err.Error()) } - database.TruncateAlliances() - CreateTestAlliances(database, 17) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) + _, err = NewSingleEliminationBracket(17) if assert.NotNil(t, err) { - assert.Equal(t, "Round of depth 32 is not supported", err.Error()) + assert.Equal(t, "Must have at most 16 alliances", err.Error()) } - database.TruncateAlliances() } -func TestEliminationSchedulePopulatePartialMatch(t *testing.T) { +func TestSingleEliminationPopulatePartialMatch(t *testing.T) { database := setupTestDb(t) // Final should be updated after semifinal is concluded. - CreateTestAlliances(database, 3) - UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 3) + bracket, err := NewSingleEliminationBracket(3) + assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) scoreMatch(database, "SF2-1", model.BlueWonMatch) scoreMatch(database, "SF2-2", model.BlueWonMatch) - _, err := UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err := database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 4, len(matches)) { @@ -317,19 +330,19 @@ func TestEliminationSchedulePopulatePartialMatch(t *testing.T) { database.TruncateMatchResults() // Final should be generated and populated as both semifinals conclude. - CreateTestAlliances(database, 4) - UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 4) + bracket, err = NewSingleEliminationBracket(4) + assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) scoreMatch(database, "SF2-1", model.RedWonMatch) scoreMatch(database, "SF2-2", model.RedWonMatch) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err = database.GetMatchesByType("elimination") assert.Nil(t, err) assert.Equal(t, 4, len(matches)) scoreMatch(database, "SF1-1", model.RedWonMatch) scoreMatch(database, "SF1-2", model.RedWonMatch) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err = database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 6, len(matches)) { @@ -341,29 +354,27 @@ func TestEliminationSchedulePopulatePartialMatch(t *testing.T) { database.TruncateMatchResults() } -func TestEliminationScheduleCreateNextRound(t *testing.T) { +func TestSingleEliminationCreateNextRound(t *testing.T) { database := setupTestDb(t) - CreateTestAlliances(database, 4) - UpdateEliminationSchedule(database, time.Unix(0, 0)) - scoreMatch(database, "SF1-1", model.BlueWonMatch) - _, err := UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 4) + bracket, err := NewSingleEliminationBracket(4) assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + scoreMatch(database, "SF1-1", model.BlueWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, _ := database.GetMatchesByType("elimination") assert.Equal(t, 4, len(matches)) scoreMatch(database, "SF2-1", model.BlueWonMatch) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, _ = database.GetMatchesByType("elimination") assert.Equal(t, 4, len(matches)) scoreMatch(database, "SF1-2", model.BlueWonMatch) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, _ = database.GetMatchesByType("elimination") assert.Equal(t, 4, len(matches)) scoreMatch(database, "SF2-2", model.BlueWonMatch) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, _ = database.GetMatchesByType("elimination") if assert.Equal(t, 6, len(matches)) { assertMatch(t, matches[4], "F-1", 4, 3) @@ -371,29 +382,31 @@ func TestEliminationScheduleCreateNextRound(t *testing.T) { } } -func TestEliminationScheduleDetermineWinner(t *testing.T) { +func TestSingleEliminationDetermineWinner(t *testing.T) { database := setupTestDb(t) // Round with one tie and a sweep. - CreateTestAlliances(database, 2) - UpdateEliminationSchedule(database, time.Unix(0, 0)) - scoreMatch(database, "F-1", model.TieMatch) - won, err := UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 2) + bracket, err := NewSingleEliminationBracket(2) assert.Nil(t, err) - assert.False(t, won) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + scoreMatch(database, "F-1", model.TieMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + assert.False(t, bracket.IsComplete()) + assert.Equal(t, 0, bracket.Winner()) + assert.Equal(t, 0, bracket.Finalist()) matches, _ := database.GetMatchesByType("elimination") assert.Equal(t, 3, len(matches)) scoreMatch(database, "F-2", model.BlueWonMatch) - won, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) - assert.False(t, won) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + assert.False(t, bracket.IsComplete()) matches, _ = database.GetMatchesByType("elimination") assert.Equal(t, 3, len(matches)) scoreMatch(database, "F-3", model.BlueWonMatch) - won, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - if assert.Nil(t, err) { - assert.True(t, won) - } + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + assert.True(t, bracket.IsComplete()) + assert.Equal(t, 2, bracket.Winner()) + assert.Equal(t, 1, bracket.Finalist()) matches, _ = database.GetMatchesByType("elimination") assert.Equal(t, 3, len(matches)) database.TruncateAlliances() @@ -401,78 +414,72 @@ func TestEliminationScheduleDetermineWinner(t *testing.T) { database.TruncateMatchResults() // Round with one tie and a split. - CreateTestAlliances(database, 2) - UpdateEliminationSchedule(database, time.Unix(0, 0)) - scoreMatch(database, "F-1", model.RedWonMatch) - won, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 2) + bracket, err = NewSingleEliminationBracket(2) assert.Nil(t, err) - assert.False(t, won) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + scoreMatch(database, "F-1", model.RedWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + assert.False(t, bracket.IsComplete()) matches, _ = database.GetMatchesByType("elimination") assert.Equal(t, 2, len(matches)) scoreMatch(database, "F-2", model.TieMatch) - won, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) - assert.False(t, won) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + assert.False(t, bracket.IsComplete()) matches, _ = database.GetMatchesByType("elimination") assert.Equal(t, 3, len(matches)) scoreMatch(database, "F-3", model.BlueWonMatch) - won, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) - assert.False(t, won) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + assert.False(t, bracket.IsComplete()) matches, _ = database.GetMatchesByType("elimination") assert.Equal(t, 4, len(matches)) assert.Equal(t, "F-4", matches[3].DisplayName) scoreMatch(database, "F-4", model.TieMatch) - won, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) - assert.False(t, won) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + assert.False(t, bracket.IsComplete()) scoreMatch(database, "F-5", model.RedWonMatch) - won, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - if assert.Nil(t, err) { - assert.True(t, won) - } + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + assert.True(t, bracket.IsComplete()) + assert.Equal(t, 1, bracket.Winner()) + assert.Equal(t, 2, bracket.Finalist()) database.TruncateAlliances() database.TruncateMatches() database.TruncateMatchResults() // Round with two ties. - CreateTestAlliances(database, 2) - UpdateEliminationSchedule(database, time.Unix(0, 0)) - scoreMatch(database, "F-1", model.TieMatch) - won, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 2) + bracket, err = NewSingleEliminationBracket(2) assert.Nil(t, err) - assert.False(t, won) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + scoreMatch(database, "F-1", model.TieMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + assert.False(t, bracket.IsComplete()) matches, _ = database.GetMatchesByType("elimination") assert.Equal(t, 3, len(matches)) scoreMatch(database, "F-2", model.BlueWonMatch) - won, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) - assert.False(t, won) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + assert.False(t, bracket.IsComplete()) matches, _ = database.GetMatchesByType("elimination") assert.Equal(t, 3, len(matches)) scoreMatch(database, "F-3", model.TieMatch) - won, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) - assert.False(t, won) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + assert.False(t, bracket.IsComplete()) matches, _ = database.GetMatchesByType("elimination") if assert.Equal(t, 4, len(matches)) { assert.Equal(t, "F-4", matches[3].DisplayName) } scoreMatch(database, "F-4", model.BlueWonMatch) - won, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - if assert.Nil(t, err) { - assert.True(t, won) - } + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + assert.True(t, bracket.IsComplete()) database.TruncateAlliances() database.TruncateMatches() database.TruncateMatchResults() // Round with repeated ties. - CreateTestAlliances(database, 2) + tournament.CreateTestAlliances(database, 2) updateAndAssertSchedule := func(expectedNumMatches int, expectedWon bool) { - won, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) - assert.Equal(t, expectedWon, won) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + assert.Equal(t, expectedWon, bracket.IsComplete()) matches, _ = database.GetMatchesByType("elimination") assert.Equal(t, expectedNumMatches, len(matches)) } @@ -497,65 +504,59 @@ func TestEliminationScheduleDetermineWinner(t *testing.T) { updateAndAssertSchedule(9, true) } -func TestEliminationScheduleRemoveUnneededMatches(t *testing.T) { +func TestSingleEliminationRemoveUnneededMatches(t *testing.T) { database := setupTestDb(t) - CreateTestAlliances(database, 2) - UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 2) + bracket, err := NewSingleEliminationBracket(2) + assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) scoreMatch(database, "F-1", model.RedWonMatch) scoreMatch(database, "F-2", model.TieMatch) - _, err := UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, _ := database.GetMatchesByType("elimination") assert.Equal(t, 3, len(matches)) // Check that the third match is deleted if the score is changed. scoreMatch(database, "F-2", model.RedWonMatch) - won, err := UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) - assert.True(t, won) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + assert.True(t, bracket.IsComplete()) // Check that the deleted match is recreated if the score is changed. scoreMatch(database, "F-2", model.BlueWonMatch) - won, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) - assert.False(t, won) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + assert.False(t, bracket.IsComplete()) matches, _ = database.GetMatchesByType("elimination") if assert.Equal(t, 3, len(matches)) { assert.Equal(t, "F-3", matches[2].DisplayName) } } -func TestEliminationScheduleChangePreviousRoundResult(t *testing.T) { +func TestSingleEliminationChangePreviousRoundResult(t *testing.T) { database := setupTestDb(t) - CreateTestAlliances(database, 4) - _, err := UpdateEliminationSchedule(database, time.Unix(0, 0)) + tournament.CreateTestAlliances(database, 4) + bracket, err := NewSingleEliminationBracket(4) assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) scoreMatch(database, "SF2-1", model.RedWonMatch) scoreMatch(database, "SF2-2", model.BlueWonMatch) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) scoreMatch(database, "SF2-3", model.RedWonMatch) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) scoreMatch(database, "SF2-3", model.BlueWonMatch) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err := database.GetMatchesByType("elimination") assert.Nil(t, err) assert.Equal(t, 5, len(matches)) scoreMatch(database, "SF1-1", model.RedWonMatch) scoreMatch(database, "SF1-2", model.RedWonMatch) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) scoreMatch(database, "SF1-2", model.BlueWonMatch) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) scoreMatch(database, "SF1-3", model.BlueWonMatch) - _, err = UpdateEliminationSchedule(database, time.Unix(0, 0)) - assert.Nil(t, err) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) matches, err = database.GetMatchesByType("elimination") assert.Nil(t, err) if assert.Equal(t, 8, len(matches)) { @@ -563,107 +564,3 @@ func TestEliminationScheduleChangePreviousRoundResult(t *testing.T) { assertMatch(t, matches[7], "F-2", 4, 3) } } - -func TestEliminationScheduleTiming(t *testing.T) { - database := setupTestDb(t) - - CreateTestAlliances(database, 4) - UpdateEliminationSchedule(database, time.Unix(1000, 0)) - matches, err := database.GetMatchesByType("elimination") - assert.Nil(t, err) - if assert.Equal(t, 4, len(matches)) { - assert.Equal(t, int64(1000), matches[0].Time.Unix()) - assert.Equal(t, int64(1600), matches[1].Time.Unix()) - assert.Equal(t, int64(2200), matches[2].Time.Unix()) - assert.Equal(t, int64(2800), matches[3].Time.Unix()) - } - scoreMatch(database, "SF1-1", model.RedWonMatch) - scoreMatch(database, "SF1-2", model.BlueWonMatch) - UpdateEliminationSchedule(database, time.Unix(5000, 0)) - matches, err = database.GetMatchesByType("elimination") - assert.Nil(t, err) - if assert.Equal(t, 5, len(matches)) { - assert.Equal(t, int64(1000), matches[0].Time.Unix()) - assert.Equal(t, int64(5000), matches[1].Time.Unix()) - assert.Equal(t, int64(2200), matches[2].Time.Unix()) - assert.Equal(t, int64(5600), matches[3].Time.Unix()) - assert.Equal(t, int64(6200), matches[4].Time.Unix()) - } -} - -func TestEliminationScheduleTeamPositions(t *testing.T) { - database := setupTestDb(t) - - CreateTestAlliances(database, 4) - UpdateEliminationSchedule(database, time.Unix(1000, 0)) - matches, _ := database.GetMatchesByType("elimination") - match1 := matches[0] - match2 := matches[1] - assert.Equal(t, 102, match1.Red1) - assert.Equal(t, 101, match1.Red2) - assert.Equal(t, 103, match1.Red3) - assert.Equal(t, 302, match2.Blue1) - assert.Equal(t, 301, match2.Blue2) - assert.Equal(t, 303, match2.Blue3) - - // Shuffle the team positions and check that the subsequent matches in the same round have the same ones. - match1.Red1, match1.Red2 = match1.Red2, 104 - match2.Blue1, match2.Blue3 = 305, match2.Blue1 - database.UpdateMatch(&match1) - database.UpdateMatch(&match2) - scoreMatch(database, "SF1-1", model.RedWonMatch) - scoreMatch(database, "SF2-1", model.BlueWonMatch) - UpdateEliminationSchedule(database, time.Unix(1000, 0)) - matches, _ = database.GetMatchesByType("elimination") - if assert.Equal(t, 4, len(matches)) { - assert.Equal(t, match1.Red1, matches[0].Red1) - assert.Equal(t, match1.Red2, matches[0].Red2) - assert.Equal(t, match1.Red3, matches[0].Red3) - assert.Equal(t, match1.Red1, matches[2].Red1) - assert.Equal(t, match1.Red2, matches[2].Red2) - assert.Equal(t, match1.Red3, matches[2].Red3) - - assert.Equal(t, match2.Blue1, matches[1].Blue1) - assert.Equal(t, match2.Blue2, matches[1].Blue2) - assert.Equal(t, match2.Blue3, matches[1].Blue3) - assert.Equal(t, match2.Blue1, matches[3].Blue1) - assert.Equal(t, match2.Blue2, matches[3].Blue2) - assert.Equal(t, match2.Blue3, matches[3].Blue3) - } - - // Advance them to the finals and verify that the team position updates have been propagated. - scoreMatch(database, "SF1-2", model.RedWonMatch) - scoreMatch(database, "SF2-2", model.BlueWonMatch) - UpdateEliminationSchedule(database, time.Unix(5000, 0)) - matches, _ = database.GetMatchesByType("elimination") - if assert.Equal(t, 6, len(matches)) { - for i := 4; i < 6; i++ { - assert.Equal(t, match1.Red1, matches[i].Red1) - assert.Equal(t, match1.Red2, matches[i].Red2) - assert.Equal(t, match1.Red3, matches[i].Red3) - assert.Equal(t, match2.Blue1, matches[i].Blue1) - assert.Equal(t, match2.Blue2, matches[i].Blue2) - assert.Equal(t, match2.Blue3, matches[i].Blue3) - } - } -} - -func assertMatch(t *testing.T, match model.Match, displayName string, redAlliance int, blueAlliance int) { - assert.Equal(t, displayName, match.DisplayName) - assert.Equal(t, redAlliance, match.ElimRedAlliance) - assert.Equal(t, blueAlliance, match.ElimBlueAlliance) - assert.Equal(t, 100*redAlliance+2, match.Red1) - assert.Equal(t, 100*redAlliance+1, match.Red2) - assert.Equal(t, 100*redAlliance+3, match.Red3) - assert.Equal(t, 100*blueAlliance+2, match.Blue1) - assert.Equal(t, 100*blueAlliance+1, match.Blue2) - assert.Equal(t, 100*blueAlliance+3, match.Blue3) -} - -func scoreMatch(database *model.Database, displayName string, winner model.MatchStatus) { - match, _ := database.GetMatchByName("elimination", displayName) - match.Status = winner - database.UpdateMatch(match) - UpdateAlliance(database, [3]int{match.Red1, match.Red2, match.Red3}, match.ElimRedAlliance) - UpdateAlliance(database, [3]int{match.Blue1, match.Blue2, match.Blue3}, match.ElimBlueAlliance) -} diff --git a/bracket/test_helpers.go b/bracket/test_helpers.go new file mode 100644 index 0000000..2a91d25 --- /dev/null +++ b/bracket/test_helpers.go @@ -0,0 +1,36 @@ +// Copyright 2022 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) +// +// Helper methods for use in tests in this package and others. + +package bracket + +import ( + "github.com/Team254/cheesy-arena-lite/model" + "github.com/stretchr/testify/assert" + "testing" +) + +func setupTestDb(t *testing.T) *model.Database { + return model.SetupTestDb(t, "bracket") +} + +func assertMatch(t *testing.T, match model.Match, displayName string, redAlliance int, blueAlliance int) { + assert.Equal(t, displayName, match.DisplayName) + assert.Equal(t, redAlliance, match.ElimRedAlliance) + assert.Equal(t, blueAlliance, match.ElimBlueAlliance) + assert.Equal(t, 100*redAlliance+2, match.Red1) + assert.Equal(t, 100*redAlliance+1, match.Red2) + assert.Equal(t, 100*redAlliance+3, match.Red3) + assert.Equal(t, 100*blueAlliance+2, match.Blue1) + assert.Equal(t, 100*blueAlliance+1, match.Blue2) + assert.Equal(t, 100*blueAlliance+3, match.Blue3) +} + +func scoreMatch(database *model.Database, displayName string, winner model.MatchStatus) { + match, _ := database.GetMatchByName("elimination", displayName) + match.Status = winner + database.UpdateMatch(match) + database.UpdateAllianceFromMatch(match.ElimRedAlliance, [3]int{match.Red1, match.Red2, match.Red3}) + database.UpdateAllianceFromMatch(match.ElimBlueAlliance, [3]int{match.Blue1, match.Blue2, match.Blue3}) +} diff --git a/field/arena.go b/field/arena.go index e3b83aa..586c974 100755 --- a/field/arena.go +++ b/field/arena.go @@ -7,6 +7,7 @@ package field import ( "fmt" + "github.com/Team254/cheesy-arena-lite/bracket" "github.com/Team254/cheesy-arena-lite/game" "github.com/Team254/cheesy-arena-lite/model" "github.com/Team254/cheesy-arena-lite/network" @@ -71,6 +72,7 @@ type Arena struct { SavedRankings game.Rankings AllianceStationDisplayMode string AllianceSelectionAlliances []model.Alliance + PlayoffBracket *bracket.Bracket LowerThird *model.LowerThird ShowLowerThird bool MuteMatchSounds bool @@ -112,6 +114,14 @@ func NewArena(dbPath string) (*Arena, error) { arena.Displays = make(map[string]*Display) + // Reconstruct the playoff bracket in memory. + if err = arena.CreatePlayoffBracket(); err != nil { + return nil, err + } + if err = arena.UpdatePlayoffBracket(nil); err != nil { + return nil, err + } + // Load empty match as current. arena.MatchState = PreMatch arena.LoadTestMatch() @@ -164,6 +174,30 @@ func (arena *Arena) LoadSettings() error { return nil } +// Constructs an empty playoff bracket in memory, based only on the number of alliances. +func (arena *Arena) CreatePlayoffBracket() error { + alliances, err := arena.Database.GetAllAlliances() + if err != nil { + return err + } + if len(alliances) > 0 { + arena.PlayoffBracket, err = bracket.NewSingleEliminationBracket(len(alliances)) + if err != nil { + return err + } + } + return nil +} + +// Traverses the in-memory playoff bracket to populate alliances, create matches, and assess winners. Does nothing if +// the bracket has not been created.are +func (arena *Arena) UpdatePlayoffBracket(startTime *time.Time) error { + if arena.PlayoffBracket == nil { + return nil + } + return arena.PlayoffBracket.Update(arena.Database, startTime) +} + // Sets up the arena for the given match. func (arena *Arena) LoadMatch(match *model.Match) error { if arena.MatchState != PreMatch { diff --git a/model/alliance.go b/model/alliance.go index 8358576..40910c3 100644 --- a/model/alliance.go +++ b/model/alliance.go @@ -44,6 +44,39 @@ func (database *Database) GetAllAlliances() ([]Alliance, error) { return alliances, nil } +// Updates the alliance, if necessary, to include whoever played in the match, in case there was a substitute. +func (database *Database) UpdateAllianceFromMatch(allianceId int, matchTeamIds [3]int) error { + alliance, err := database.GetAllianceById(allianceId) + if err != nil { + return err + } + + changed := false + if matchTeamIds != alliance.Lineup { + alliance.Lineup = matchTeamIds + changed = true + } + + for _, teamId := range matchTeamIds { + found := false + for _, allianceTeamId := range alliance.TeamIds { + if teamId == allianceTeamId { + found = true + break + } + } + if !found { + alliance.TeamIds = append(alliance.TeamIds, teamId) + changed = true + } + } + + if changed { + return database.UpdateAlliance(alliance) + } + return nil +} + // Returns two arrays containing the IDs of any teams for the red and blue alliances, respectively, who are part of the // elimination alliance but are not playing in the given match. // If the given match isn't an elimination match, empty arrays are returned. diff --git a/model/alliance_test.go b/model/alliance_test.go index a8fc29e..c66bb97 100644 --- a/model/alliance_test.go +++ b/model/alliance_test.go @@ -39,6 +39,19 @@ func TestAllianceCrud(t *testing.T) { assert.Nil(t, alliance2) } +func TestUpdateAllianceFromMatch(t *testing.T) { + db := setupTestDb(t) + defer db.Close() + + alliance := Alliance{Id: 3, TeamIds: []int{254, 1114, 296, 1503}, Lineup: [3]int{1114, 254, 296}} + assert.Nil(t, db.CreateAlliance(&alliance)) + assert.Nil(t, db.UpdateAllianceFromMatch(3, [3]int{1503, 188, 296})) + alliance2, err := db.GetAllianceById(3) + assert.Nil(t, err) + assert.Equal(t, []int{254, 1114, 296, 1503, 188}, alliance2.TeamIds) + assert.Equal(t, [3]int{1503, 188, 296}, alliance2.Lineup) +} + func TestTruncateAllianceTeams(t *testing.T) { db := setupTestDb(t) defer db.Close() diff --git a/model/match.go b/model/match.go index 3350506..6cc7819 100644 --- a/model/match.go +++ b/model/match.go @@ -48,7 +48,7 @@ const ( MatchNotPlayed MatchStatus = "" ) -var ElimRoundNames = map[int]string{1: "F", 2: "SF", 4: "QF", 8: "EF"} +var elimRoundNames = map[int]string{1: "F", 2: "SF", 4: "QF", 8: "EF"} func (database *Database) CreateMatch(match *Match) error { return database.matchTable.create(match) @@ -153,7 +153,7 @@ func (match *Match) TbaCode() string { if match.Type == "qualification" { return fmt.Sprintf("qm%s", match.DisplayName) } else if match.Type == "elimination" { - return fmt.Sprintf("%s%dm%d", strings.ToLower(ElimRoundNames[match.ElimRound]), match.ElimGroup, + return fmt.Sprintf("%s%dm%d", strings.ToLower(elimRoundNames[match.ElimRound]), match.ElimGroup, match.ElimInstance) } return "" diff --git a/tournament/elimination_schedule.go b/tournament/elimination_schedule.go deleted file mode 100644 index b6ce3ed..0000000 --- a/tournament/elimination_schedule.go +++ /dev/null @@ -1,262 +0,0 @@ -// Copyright 2014 Team 254. All Rights Reserved. -// Author: pat@patfairbank.com (Patrick Fairbank) -// -// Functions for creating and updating the elimination match schedule. - -package tournament - -import ( - "fmt" - "github.com/Team254/cheesy-arena-lite/model" - "strconv" - "time" -) - -const ElimMatchSpacingSec = 600 -const numWinsToAdvance = 2 - -// Incrementally creates any elimination matches that can be created, based on the results of alliance -// selection or prior elimination rounds. Returns true if the tournament is won. -func UpdateEliminationSchedule(database *model.Database, startTime time.Time) (bool, error) { - alliances, err := database.GetAllAlliances() - if err != nil { - return false, err - } - winner, err := buildEliminationMatchSet(database, 1, 1, len(alliances)) - if err != nil { - return false, err - } - - // Update the scheduled time for all matches that have yet to be run. - matches, err := database.GetMatchesByType("elimination") - if err != nil { - return false, err - } - matchIndex := 0 - for _, match := range matches { - if match.IsComplete() { - continue - } - match.Time = startTime.Add(time.Duration(matchIndex*ElimMatchSpacingSec) * time.Second) - if err = database.UpdateMatch(&match); err != nil { - return false, err - } - matchIndex++ - } - - return winner != nil, err -} - -// Updates the alliance, if necessary, to include whoever played in the match, in case there was a substitute. -func UpdateAlliance(database *model.Database, matchTeamIds [3]int, allianceId int) error { - alliance, err := database.GetAllianceById(allianceId) - if err != nil { - return err - } - - changed := false - if matchTeamIds != alliance.Lineup { - alliance.Lineup = matchTeamIds - changed = true - } - - for _, teamId := range matchTeamIds { - found := false - for _, allianceTeamId := range alliance.TeamIds { - if teamId == allianceTeamId { - found = true - break - } - } - if !found { - alliance.TeamIds = append(alliance.TeamIds, teamId) - changed = true - } - } - - if changed { - return database.UpdateAlliance(alliance) - } - return nil -} - -// Recursively traverses the elimination bracket downwards, creating matches as necessary. Returns the winner -// of the given round if known. -func buildEliminationMatchSet( - database *model.Database, round int, group int, numAlliances int, -) (*model.Alliance, error) { - if numAlliances < 2 { - return nil, fmt.Errorf("Must have at least 2 alliances") - } - roundName, ok := model.ElimRoundNames[round] - if !ok { - return nil, fmt.Errorf("Round of depth %d is not supported", round*2) - } - if round != 1 { - roundName += strconv.Itoa(group) - } - - // Recurse to figure out who the involved alliances are. - var redAlliance, blueAlliance *model.Alliance - var err error - if numAlliances < 4*round { - // This is the first round for some or all alliances and will be at least partially populated from the - // alliance selection results. - matchups := []int{1, 16, 8, 9, 4, 13, 5, 12, 2, 15, 7, 10, 3, 14, 6, 11} - factor := len(matchups) / round - redAllianceNumber := matchups[(group-1)*factor] - blueAllianceNumber := matchups[(group-1)*factor+factor/2] - numDirectAlliances := 4*round - numAlliances - if redAllianceNumber <= numDirectAlliances { - // The red alliance has a bye or the number of alliances is a power of 2; get from alliance selection. - redAlliance, err = database.GetAllianceById(redAllianceNumber) - if err != nil { - return nil, err - } - } - if blueAllianceNumber <= numDirectAlliances { - // The blue alliance has a bye or the number of alliances is a power of 2; get from alliance selection. - blueAlliance, err = database.GetAllianceById(blueAllianceNumber) - if err != nil { - return nil, err - } - } - } - - // If the alliances aren't known yet, get them from one round down in the bracket. - if redAlliance == nil { - redAlliance, err = buildEliminationMatchSet(database, round*2, group*2-1, numAlliances) - if err != nil { - return nil, err - } - } - if blueAlliance == nil { - blueAlliance, err = buildEliminationMatchSet(database, round*2, group*2, numAlliances) - if err != nil { - return nil, err - } - } - - // Bail if the rounds below are not yet complete and we don't know both alliances competing this round. - if redAlliance == nil || blueAlliance == nil { - return nil, nil - } - - // Create, update, and/or delete unplayed matches as necessary. - matches, err := database.GetMatchesByElimRoundGroup(round, group) - if err != nil { - return nil, err - } - var redAllianceWins, blueAllianceWins int - var unplayedMatches []model.Match - for _, match := range matches { - if !match.IsComplete() { - // Update the teams in the match if they are not yet set or are incorrect. - changed := false - if match.Red1 != redAlliance.Lineup[0] || match.Red2 != redAlliance.Lineup[1] || - match.Red3 != redAlliance.Lineup[2] { - positionRedTeams(&match, redAlliance) - match.ElimRedAlliance = redAlliance.Id - changed = true - if err = database.UpdateMatch(&match); err != nil { - return nil, err - } - } - if match.Blue1 != blueAlliance.Lineup[0] || match.Blue2 != blueAlliance.Lineup[1] || - match.Blue3 != blueAlliance.Lineup[2] { - positionBlueTeams(&match, blueAlliance) - match.ElimBlueAlliance = blueAlliance.Id - changed = true - } - if changed { - if err = database.UpdateMatch(&match); err != nil { - return nil, err - } - } - - unplayedMatches = append(unplayedMatches, match) - continue - } - - // Check who won. - if match.Status == model.RedWonMatch { - redAllianceWins++ - } else if match.Status == model.BlueWonMatch { - blueAllianceWins++ - } - } - - maxWins := redAllianceWins - if blueAllianceWins > maxWins { - maxWins = blueAllianceWins - } - numUnplayedMatchesNeeded := numWinsToAdvance - maxWins - if len(unplayedMatches) > numUnplayedMatchesNeeded { - // Delete any superfluous matches off the end of the list. - for i := 0; i < len(unplayedMatches)-numUnplayedMatchesNeeded; i++ { - if err = database.DeleteMatch(unplayedMatches[len(unplayedMatches)-i-1].Id); err != nil { - return nil, err - } - } - } else if len(unplayedMatches) < numUnplayedMatchesNeeded { - // Create initial set of matches or any additional required matches due to tie matches or ties in the round. - for i := 0; i < numUnplayedMatchesNeeded-len(unplayedMatches); i++ { - err = database.CreateMatch( - createMatch(roundName, round, group, len(matches)+i+1, redAlliance, blueAlliance), - ) - if err != nil { - return nil, err - } - } - } - - // Determine the winner of the round or if it is still in progress. - if redAllianceWins >= numWinsToAdvance { - return redAlliance, nil - } - if blueAllianceWins >= numWinsToAdvance { - return blueAlliance, nil - } - return nil, nil -} - -// Creates a match at the given point in the elimination bracket and populates the teams. -func createMatch( - roundName string, - round int, - group int, - instance int, - redAlliance, - blueAlliance *model.Alliance, -) *model.Match { - match := model.Match{ - Type: "elimination", - DisplayName: fmt.Sprintf("%s-%d", roundName, instance), - ElimRound: round, - ElimGroup: group, - ElimInstance: instance, - } - if redAlliance != nil { - match.ElimRedAlliance = redAlliance.Id - positionRedTeams(&match, redAlliance) - } - if blueAlliance != nil { - match.ElimBlueAlliance = blueAlliance.Id - positionBlueTeams(&match, blueAlliance) - } - return &match -} - -// Assigns the lineup from the alliance into the red team slots for the match. -func positionRedTeams(match *model.Match, alliance *model.Alliance) { - match.Red1 = alliance.Lineup[0] - match.Red2 = alliance.Lineup[1] - match.Red3 = alliance.Lineup[2] -} - -// Assigns the lineup from the alliance into the blue team slots for the match. -func positionBlueTeams(match *model.Match, alliance *model.Alliance) { - match.Blue1 = alliance.Lineup[0] - match.Blue2 = alliance.Lineup[1] - match.Blue3 = alliance.Lineup[2] -} diff --git a/web/alliance_selection.go b/web/alliance_selection.go index 8ca618f..f72f1e5 100755 --- a/web/alliance_selection.go +++ b/web/alliance_selection.go @@ -8,7 +8,6 @@ package web import ( "fmt" "github.com/Team254/cheesy-arena-lite/model" - "github.com/Team254/cheesy-arena-lite/tournament" "net/http" "strconv" "time" @@ -159,6 +158,12 @@ func (web *Web) allianceSelectionResetHandler(w http.ResponseWriter, r *http.Req return } + // Replace the current in-memory bracket if it was populated with teams. + if err = web.arena.CreatePlayoffBracket(); err != nil { + handleWebErr(w, err) + return + } + web.arena.AllianceSelectionAlliances = []model.Alliance{} cachedRankedTeams = []*RankedTeam{} web.arena.AllianceSelectionNotifier.Notify() @@ -209,8 +214,11 @@ func (web *Web) allianceSelectionFinalizeHandler(w http.ResponseWriter, r *http. } // Generate the first round of elimination matches. - _, err = tournament.UpdateEliminationSchedule(web.arena.Database, startTime) - if err != nil { + if err = web.arena.CreatePlayoffBracket(); err != nil { + handleWebErr(w, err) + return + } + if err = web.arena.UpdatePlayoffBracket(&startTime); err != nil { handleWebErr(w, err) return } diff --git a/web/match_play.go b/web/match_play.go index 342d1bc..88e3d33 100755 --- a/web/match_play.go +++ b/web/match_play.go @@ -7,6 +7,7 @@ package web import ( "fmt" + "github.com/Team254/cheesy-arena-lite/bracket" "github.com/Team254/cheesy-arena-lite/field" "github.com/Team254/cheesy-arena-lite/game" "github.com/Team254/cheesy-arena-lite/model" @@ -436,36 +437,30 @@ func (web *Web) commitMatchScore(match *model.Match, matchResult *model.MatchRes } if match.ShouldUpdateEliminationMatches() { - if err = tournament.UpdateAlliance( - web.arena.Database, [3]int{match.Red1, match.Red2, match.Red3}, match.ElimRedAlliance, + if err = web.arena.Database.UpdateAllianceFromMatch( + match.ElimRedAlliance, [3]int{match.Red1, match.Red2, match.Red3}, ); err != nil { return err } - if err = tournament.UpdateAlliance( - web.arena.Database, [3]int{match.Blue1, match.Blue2, match.Blue3}, match.ElimBlueAlliance, + if err = web.arena.Database.UpdateAllianceFromMatch( + match.ElimBlueAlliance, [3]int{match.Blue1, match.Blue2, match.Blue3}, ); err != nil { return err } // Generate any subsequent elimination matches. - isTournamentWon, err := tournament.UpdateEliminationSchedule(web.arena.Database, - time.Now().Add(time.Second*tournament.ElimMatchSpacingSec)) - if err != nil { + nextMatchTime := time.Now().Add(time.Second * bracket.ElimMatchSpacingSec) + if err = web.arena.UpdatePlayoffBracket(&nextMatchTime); err != nil { return err } // Generate awards if the tournament is over. - if isTournamentWon { - var winnerAllianceId, finalistAllianceId int - if match.Status == model.RedWonMatch { - winnerAllianceId = match.ElimRedAlliance - finalistAllianceId = match.ElimBlueAlliance - } else if match.Status == model.BlueWonMatch { - winnerAllianceId = match.ElimBlueAlliance - finalistAllianceId = match.ElimRedAlliance - } - if err = tournament.CreateOrUpdateWinnerAndFinalistAwards(web.arena.Database, winnerAllianceId, - finalistAllianceId); err != nil { + if web.arena.PlayoffBracket.IsComplete() { + winnerAllianceId := web.arena.PlayoffBracket.Winner() + finalistAllianceId := web.arena.PlayoffBracket.Finalist() + if err = tournament.CreateOrUpdateWinnerAndFinalistAwards( + web.arena.Database, winnerAllianceId, finalistAllianceId, + ); err != nil { return err } } diff --git a/web/match_play_test.go b/web/match_play_test.go index c680cef..112157c 100644 --- a/web/match_play_test.go +++ b/web/match_play_test.go @@ -184,6 +184,7 @@ func TestCommitEliminationTie(t *testing.T) { assert.Equal(t, model.TieMatch, match.Status) tournament.CreateTestAlliances(web.arena.Database, 2) + web.arena.CreatePlayoffBracket() match.Type = "elimination" match.ElimRedAlliance = 1 match.ElimBlueAlliance = 2 diff --git a/web/match_review_test.go b/web/match_review_test.go index cc44e09..ad0d3bb 100644 --- a/web/match_review_test.go +++ b/web/match_review_test.go @@ -45,6 +45,7 @@ func TestMatchReviewEditExistingResult(t *testing.T) { matchResult.MatchType = match.Type assert.Nil(t, web.arena.Database.CreateMatchResult(matchResult)) tournament.CreateTestAlliances(web.arena.Database, 2) + web.arena.CreatePlayoffBracket() recorder := web.getHttpResponse("/match_review") assert.Equal(t, 200, recorder.Code) @@ -85,6 +86,7 @@ func TestMatchReviewCreateNewResult(t *testing.T) { Red2: 1002, Red3: 1003, Blue1: 1004, Blue2: 1005, Blue3: 1006, ElimRedAlliance: 1, ElimBlueAlliance: 2} web.arena.Database.CreateMatch(&match) tournament.CreateTestAlliances(web.arena.Database, 2) + web.arena.CreatePlayoffBracket() recorder := web.getHttpResponse("/match_review") assert.Equal(t, 200, recorder.Code)