diff --git a/bracket/bracket.go b/bracket/bracket.go index 3c3a6e5..33b66d6 100644 --- a/bracket/bracket.go +++ b/bracket/bracket.go @@ -26,7 +26,13 @@ func newBracket(matchupTemplates []matchupTemplate, numAlliances int) (*Bracket, } // Recursively build the bracket, starting with the finals matchup. - finalsMatchup, _, err := createMatchupTree(newMatchupKey(1, 1), matchupTemplateMap, numAlliances) + finalsMatchup, _, err := createMatchupGraph( + newMatchupKey(1, 1), + true, + matchupTemplateMap, + numAlliances, + make(map[matchupKey]*Matchup), + ) if err != nil { return nil, err } @@ -35,8 +41,12 @@ func newBracket(matchupTemplates []matchupTemplate, numAlliances int) (*Bracket, } // Recursive helper method to create the current matchup node and all of its children. -func createMatchupTree( - matchupKey matchupKey, matchupTemplateMap map[matchupKey]matchupTemplate, numAlliances int, +func createMatchupGraph( + matchupKey matchupKey, + useWinner bool, + matchupTemplateMap map[matchupKey]matchupTemplate, + numAlliances int, + matchupMap map[matchupKey]*Matchup, ) (*Matchup, int, error) { matchupTemplate, ok := matchupTemplateMap[matchupKey] if !ok { @@ -46,7 +56,7 @@ func createMatchupTree( 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. + // This is a leaf node in the matchup graph; 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") } @@ -62,36 +72,54 @@ func createMatchupTree( 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 + matchup, ok := matchupMap[matchupKey] + if !ok { + matchup = &Matchup{ + matchupTemplate: matchupTemplate, + RedAllianceId: redAllianceIdFromSelection, + BlueAllianceId: blueAllianceIdFromSelection, + } + matchupMap[matchupKey] = matchup + } + return matchup, 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 + if useWinner { + if redAllianceIdFromSelection > 0 { + // The red alliance has a bye. + return nil, redAllianceIdFromSelection, nil + } else { + // The blue alliance has a bye. + return nil, blueAllianceIdFromSelection, nil + } } else { - // The blue alliance has a bye. - return nil, blueAllianceIdFromSelection, nil + // There is no losing alliance to return; prune this matchup. + return nil, 0, 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, + redAllianceSourceMatchup, redByeAllianceId, err := createMatchupGraph( + matchupTemplate.redAllianceSource.matchupKey, + matchupTemplate.redAllianceSource.useWinner, + matchupTemplateMap, + numAlliances, + matchupMap, ) if err != nil { return nil, 0, err } - blueAllianceSourceMatchup, blueByeAllianceId, err := createMatchupTree( - matchupTemplate.blueAllianceSource.matchupKey, matchupTemplateMap, numAlliances, + blueAllianceSourceMatchup, blueByeAllianceId, err := createMatchupGraph( + matchupTemplate.blueAllianceSource.matchupKey, + matchupTemplate.blueAllianceSource.useWinner, + matchupTemplateMap, + numAlliances, + matchupMap, ) if err != nil { return nil, 0, err @@ -104,22 +132,37 @@ func createMatchupTree( return nil, 0, nil } if redByeAllianceId > 0 && blueAllianceSourceMatchup == nil && blueByeAllianceId == 0 { - // The red alliance has a bye. - return nil, redByeAllianceId, nil + if useWinner { + // The red alliance has a bye. + return nil, redByeAllianceId, nil + } else { + // There is no losing alliance to return; prune this matchup. + return nil, 0, nil + } } if blueByeAllianceId > 0 && redAllianceSourceMatchup == nil && redByeAllianceId == 0 { - // The blue alliance has a bye. - return nil, blueByeAllianceId, nil + if useWinner { + // The blue alliance has a bye. + return nil, blueByeAllianceId, nil + } else { + // There is no losing alliance to return; prune this matchup. + return nil, 0, 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 + matchup, ok := matchupMap[matchupKey] + if !ok { + matchup = &Matchup{ + matchupTemplate: matchupTemplate, + RedAllianceId: redByeAllianceId, + BlueAllianceId: blueByeAllianceId, + RedAllianceSourceMatchup: redAllianceSourceMatchup, + BlueAllianceSourceMatchup: blueAllianceSourceMatchup, + } + matchupMap[matchupKey] = matchup + } + return matchup, 0, nil } // Returns the winning alliance ID of the entire bracket, or 0 if it is not yet known. @@ -174,8 +217,12 @@ func (bracket *Bracket) print() { fmt.Printf("%+v\n\n", matchup) matchupQueue = matchupQueue[1:] if matchup != nil { - matchupQueue = append(matchupQueue, matchup.RedAllianceSourceMatchup) - matchupQueue = append(matchupQueue, matchup.BlueAllianceSourceMatchup) + if matchup.RedAllianceSourceMatchup != nil && matchup.redAllianceSource.useWinner { + matchupQueue = append(matchupQueue, matchup.RedAllianceSourceMatchup) + } + if matchup.BlueAllianceSourceMatchup != nil && matchup.blueAllianceSource.useWinner { + matchupQueue = append(matchupQueue, matchup.BlueAllianceSourceMatchup) + } } } } diff --git a/bracket/bracket_test.go b/bracket/bracket_test.go index 0b95416..1ed7ae6 100644 --- a/bracket/bracket_test.go +++ b/bracket/bracket_test.go @@ -20,7 +20,7 @@ func TestNewBracketErrors(t *testing.T) { matchTemplate := matchupTemplate{ matchupKey: newMatchupKey(1, 1), redAllianceSource: allianceSource{allianceId: 1}, - blueAllianceSource: newMatchupAllianceSource(2, 2), + blueAllianceSource: newWinnerAllianceSource(2, 2), } _, err = newBracket([]matchupTemplate{matchTemplate}, 8) if assert.NotNil(t, err) { @@ -35,15 +35,15 @@ func TestNewBracketInverseSeeding(t *testing.T) { matchupKey: newMatchupKey(1, 1), displayNameFormat: "F-${instance}", numWinsToAdvance: 2, - redAllianceSource: newMatchupAllianceSource(2, 1), - blueAllianceSource: newMatchupAllianceSource(2, 2), + redAllianceSource: newWinnerAllianceSource(2, 1), + blueAllianceSource: newWinnerAllianceSource(2, 2), }, { matchupKey: newMatchupKey(2, 1), displayNameFormat: "SF${group}-${instance}", numWinsToAdvance: 2, - redAllianceSource: newMatchupAllianceSource(4, 2), - blueAllianceSource: newMatchupAllianceSource(4, 1), + redAllianceSource: newWinnerAllianceSource(4, 2), + blueAllianceSource: newWinnerAllianceSource(4, 1), }, { matchupKey: newMatchupKey(2, 2), diff --git a/bracket/double_elimination.go b/bracket/double_elimination.go new file mode 100644 index 0000000..aaf546c --- /dev/null +++ b/bracket/double_elimination.go @@ -0,0 +1,117 @@ +// Copyright 2022 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) +// +// Defines the tournament structure for a double-elimination bracket culminating in a best-of-three final. + +package bracket + +import "fmt" + +// Creates an unpopulated double-elimination bracket. Only supports having exactly eight alliances. +func NewDoubleEliminationBracket(numAlliances int) (*Bracket, error) { + if numAlliances != 8 { + return nil, fmt.Errorf("Must have exactly 8 alliances") + } + return newBracket(doubleEliminationBracketMatchupTemplates, newMatchupKey(6, 1), numAlliances) +} + +var doubleEliminationBracketMatchupTemplates = []matchupTemplate{ + { + matchupKey: newMatchupKey(1, 1), + displayNameFormat: "1", + numWinsToAdvance: 1, + redAllianceSource: allianceSource{allianceId: 1}, + blueAllianceSource: allianceSource{allianceId: 8}, + }, + { + matchupKey: newMatchupKey(1, 2), + displayNameFormat: "2", + numWinsToAdvance: 1, + redAllianceSource: allianceSource{allianceId: 4}, + blueAllianceSource: allianceSource{allianceId: 5}, + }, + { + matchupKey: newMatchupKey(1, 3), + displayNameFormat: "3", + numWinsToAdvance: 1, + redAllianceSource: allianceSource{allianceId: 3}, + blueAllianceSource: allianceSource{allianceId: 6}, + }, + { + matchupKey: newMatchupKey(1, 4), + displayNameFormat: "4", + numWinsToAdvance: 1, + redAllianceSource: allianceSource{allianceId: 2}, + blueAllianceSource: allianceSource{allianceId: 7}, + }, + { + matchupKey: newMatchupKey(2, 1), + displayNameFormat: "5", + numWinsToAdvance: 1, + redAllianceSource: newLoserAllianceSource(1, 1), + blueAllianceSource: newLoserAllianceSource(1, 2), + }, + { + matchupKey: newMatchupKey(2, 2), + displayNameFormat: "6", + numWinsToAdvance: 1, + redAllianceSource: newLoserAllianceSource(1, 3), + blueAllianceSource: newLoserAllianceSource(1, 4), + }, + { + matchupKey: newMatchupKey(2, 3), + displayNameFormat: "7", + numWinsToAdvance: 1, + redAllianceSource: newWinnerAllianceSource(1, 1), + blueAllianceSource: newWinnerAllianceSource(1, 2), + }, + { + matchupKey: newMatchupKey(2, 4), + displayNameFormat: "8", + numWinsToAdvance: 1, + redAllianceSource: newWinnerAllianceSource(1, 3), + blueAllianceSource: newWinnerAllianceSource(1, 4), + }, + { + matchupKey: newMatchupKey(3, 1), + displayNameFormat: "9", + numWinsToAdvance: 1, + redAllianceSource: newLoserAllianceSource(2, 3), + blueAllianceSource: newWinnerAllianceSource(2, 2), + }, + { + matchupKey: newMatchupKey(3, 2), + displayNameFormat: "10", + numWinsToAdvance: 1, + redAllianceSource: newLoserAllianceSource(2, 4), + blueAllianceSource: newWinnerAllianceSource(2, 1), + }, + { + matchupKey: newMatchupKey(4, 1), + displayNameFormat: "11", + numWinsToAdvance: 1, + redAllianceSource: newWinnerAllianceSource(3, 1), + blueAllianceSource: newWinnerAllianceSource(3, 2), + }, + { + matchupKey: newMatchupKey(4, 2), + displayNameFormat: "12", + numWinsToAdvance: 1, + redAllianceSource: newWinnerAllianceSource(2, 3), + blueAllianceSource: newWinnerAllianceSource(2, 4), + }, + { + matchupKey: newMatchupKey(5, 1), + displayNameFormat: "13", + numWinsToAdvance: 1, + redAllianceSource: newLoserAllianceSource(4, 2), + blueAllianceSource: newWinnerAllianceSource(4, 1), + }, + { + matchupKey: newMatchupKey(6, 1), + displayNameFormat: "F-${instance}", + numWinsToAdvance: 2, + redAllianceSource: newWinnerAllianceSource(4, 2), + blueAllianceSource: newWinnerAllianceSource(5, 1), + }, +} diff --git a/bracket/double_elimination_test.go b/bracket/double_elimination_test.go new file mode 100644 index 0000000..b29c780 --- /dev/null +++ b/bracket/double_elimination_test.go @@ -0,0 +1,280 @@ +// 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" +) + +func TestDoubleEliminationInitial(t *testing.T) { + database := setupTestDb(t) + + tournament.CreateTestAlliances(database, 8) + bracket, err := NewDoubleEliminationBracket(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, 4, len(matches)) { + assertMatch(t, matches[0], "1", 1, 8) + assertMatch(t, matches[1], "2", 4, 5) + assertMatch(t, matches[2], "3", 3, 6) + assertMatch(t, matches[3], "4", 2, 7) + } +} + +func TestDoubleEliminationErrors(t *testing.T) { + _, err := NewDoubleEliminationBracket(7) + if assert.NotNil(t, err) { + assert.Equal(t, "Must have exactly 8 alliances", err.Error()) + } + + _, err = NewDoubleEliminationBracket(9) + if assert.NotNil(t, err) { + assert.Equal(t, "Must have exactly 8 alliances", err.Error()) + } +} + +func TestDoubleEliminationProgression(t *testing.T) { + database := setupTestDb(t) + + tournament.CreateTestAlliances(database, 8) + bracket, err := NewDoubleEliminationBracket(8) + 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, "1", model.BlueWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + assert.Equal(t, 4, len(matches)) + + scoreMatch(database, "2", model.RedWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + if assert.Equal(t, 6, len(matches)) { + assertMatch(t, matches[4], "5", 1, 5) + assertMatch(t, matches[5], "7", 8, 4) + } + + scoreMatch(database, "3", model.RedWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + assert.Equal(t, 6, len(matches)) + + scoreMatch(database, "4", model.BlueWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + if assert.Equal(t, 8, len(matches)) { + assertMatch(t, matches[4], "5", 1, 5) + assertMatch(t, matches[5], "6", 6, 2) + assertMatch(t, matches[6], "7", 8, 4) + assertMatch(t, matches[7], "8", 3, 7) + } + + scoreMatch(database, "5", model.BlueWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + assert.Equal(t, 8, len(matches)) + + scoreMatch(database, "6", model.BlueWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + assert.Equal(t, 8, len(matches)) + + scoreMatch(database, "7", model.BlueWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + if assert.Equal(t, 9, len(matches)) { + assertMatch(t, matches[8], "9", 8, 2) + } + + scoreMatch(database, "8", model.RedWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + if assert.Equal(t, 11, len(matches)) { + assertMatch(t, matches[9], "10", 7, 5) + assertMatch(t, matches[10], "12", 4, 3) + } + + scoreMatch(database, "9", model.RedWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + assert.Equal(t, 11, len(matches)) + + scoreMatch(database, "10", model.RedWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + if assert.Equal(t, 12, len(matches)) { + assertMatch(t, matches[10], "11", 8, 7) + assertMatch(t, matches[11], "12", 4, 3) + } + + scoreMatch(database, "11", model.BlueWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + assert.Equal(t, 12, len(matches)) + + scoreMatch(database, "12", model.RedWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + if assert.Equal(t, 13, len(matches)) { + assertMatch(t, matches[12], "13", 3, 7) + } + + scoreMatch(database, "13", model.BlueWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + if assert.Equal(t, 15, len(matches)) { + assertMatch(t, matches[13], "F-1", 4, 7) + assertMatch(t, matches[14], "F-2", 4, 7) + } + assert.False(t, bracket.IsComplete()) + assert.Equal(t, 0, bracket.Winner()) + assert.Equal(t, 0, bracket.Finalist()) + + scoreMatch(database, "F-1", model.BlueWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + assert.Equal(t, 15, len(matches)) + assert.False(t, bracket.IsComplete()) + assert.Equal(t, 0, bracket.Winner()) + assert.Equal(t, 0, bracket.Finalist()) + + scoreMatch(database, "F-2", model.RedWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + if assert.Equal(t, 16, len(matches)) { + assertMatch(t, matches[15], "F-3", 4, 7) + } + assert.False(t, bracket.IsComplete()) + assert.Equal(t, 0, bracket.Winner()) + assert.Equal(t, 0, bracket.Finalist()) + + scoreMatch(database, "F-3", model.TieMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + if assert.Equal(t, 17, len(matches)) { + assertMatch(t, matches[16], "F-4", 4, 7) + } + assert.False(t, bracket.IsComplete()) + assert.Equal(t, 0, bracket.Winner()) + assert.Equal(t, 0, bracket.Finalist()) + + scoreMatch(database, "F-4", model.TieMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + if assert.Equal(t, 18, len(matches)) { + assertMatch(t, matches[17], "F-5", 4, 7) + } + assert.False(t, bracket.IsComplete()) + assert.Equal(t, 0, bracket.Winner()) + assert.Equal(t, 0, bracket.Finalist()) + + scoreMatch(database, "F-5", model.BlueWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + assert.Equal(t, 18, len(matches)) + assert.True(t, bracket.IsComplete()) + assert.Equal(t, 7, bracket.Winner()) + assert.Equal(t, 4, bracket.Finalist()) +} + +func TestDoubleEliminationTie(t *testing.T) { + database := setupTestDb(t) + + tournament.CreateTestAlliances(database, 8) + bracket, err := NewDoubleEliminationBracket(8) + 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, "1", model.TieMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + if assert.Equal(t, 5, len(matches)) { + assertMatch(t, matches[4], "1-2", 1, 8) + } + + scoreMatch(database, "1-2", model.TieMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + if assert.Equal(t, 6, len(matches)) { + assertMatch(t, matches[5], "1-3", 1, 8) + } + + scoreMatch(database, "1-3", model.RedWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + assert.Equal(t, 6, len(matches)) +} + +func TestDoubleEliminationChangeResult(t *testing.T) { + database := setupTestDb(t) + + tournament.CreateTestAlliances(database, 8) + bracket, err := NewDoubleEliminationBracket(8) + 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, "1", model.BlueWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + assert.Equal(t, 4, len(matches)) + + scoreMatch(database, "2", model.RedWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + if assert.Equal(t, 6, len(matches)) { + assertMatch(t, matches[4], "5", 1, 5) + assertMatch(t, matches[5], "7", 8, 4) + } + + scoreMatch(database, "2", model.MatchNotPlayed) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Equal(t, 4, len(matches)) + + scoreMatch(database, "2", model.BlueWonMatch) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + if assert.Equal(t, 6, len(matches)) { + assertMatch(t, matches[4], "5", 1, 4) + assertMatch(t, matches[5], "7", 8, 5) + } +} diff --git a/bracket/matchup.go b/bracket/matchup.go index 5cda810..961f03a 100644 --- a/bracket/matchup.go +++ b/bracket/matchup.go @@ -7,6 +7,7 @@ package bracket import ( + "fmt" "github.com/Team254/cheesy-arena-lite/model" "strconv" "strings" @@ -17,6 +18,7 @@ import ( type allianceSource struct { allianceId int matchupKey matchupKey + useWinner bool } // Key for uniquely identifying a matchup. Round IDs are arbitrary and in descending order with "1" always representing @@ -49,9 +51,14 @@ type Matchup struct { 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 an alliance source that points to the winner of a different matchup. +func newWinnerAllianceSource(round, group int) allianceSource { + return allianceSource{matchupKey: newMatchupKey(round, group), useWinner: true} +} + +// Convenience method to quickly create an alliance source that points to the loser of a different matchup. +func newLoserAllianceSource(round, group int) allianceSource { + return allianceSource{matchupKey: newMatchupKey(round, group), useWinner: false} } // Convenience method to quickly create a matchup key. @@ -63,7 +70,13 @@ func newMatchupKey(round, group int) matchupKey { 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) + if strings.Contains(displayName, "${instance}") { + displayName = strings.Replace(displayName, "${instance}", strconv.Itoa(instance), -1) + } else if instance > 1 { + // Special case to handle matchups that only have more than one instance under exceptional circumstances (like + // ties in double-elimination unresolved by tiebreakers). + displayName += fmt.Sprintf("-%d", instance) + } return displayName } @@ -94,15 +107,16 @@ 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 +// Recursively traverses the matchup graph 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 { + // Update child matchups first. Only recurse down winner links to avoid visiting a node twice. + if matchup.RedAllianceSourceMatchup != nil && matchup.redAllianceSource.useWinner { if err := matchup.RedAllianceSourceMatchup.update(database); err != nil { return err } } - if matchup.BlueAllianceSourceMatchup != nil { + if matchup.BlueAllianceSourceMatchup != nil && matchup.blueAllianceSource.useWinner { if err := matchup.BlueAllianceSourceMatchup.update(database); err != nil { return err } @@ -110,10 +124,23 @@ func (matchup *Matchup) update(database *model.Database) error { // 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.redAllianceSource.useWinner { + matchup.RedAllianceId = matchup.RedAllianceSourceMatchup.winner() + } else { + matchup.RedAllianceId = matchup.RedAllianceSourceMatchup.loser() + } } if matchup.BlueAllianceSourceMatchup != nil { - matchup.BlueAllianceId = matchup.BlueAllianceSourceMatchup.winner() + if matchup.blueAllianceSource.useWinner { + matchup.BlueAllianceId = matchup.BlueAllianceSourceMatchup.winner() + } else { + matchup.BlueAllianceId = matchup.BlueAllianceSourceMatchup.loser() + } + } + + matches, err := database.GetMatchesByElimRoundGroup(matchup.round, matchup.group) + if err != nil { + return err } // Bail if we do not yet know both alliances. @@ -121,6 +148,14 @@ func (matchup *Matchup) update(database *model.Database) error { // Ensure the current state is reset; it may have previously been populated if a match result was edited. matchup.RedAllianceWins = 0 matchup.BlueAllianceWins = 0 + + // Delete any previously created matches. + for _, match := range matches { + if err = database.DeleteMatch(match.Id); err != nil { + return err + } + } + return nil } @@ -133,10 +168,6 @@ func (matchup *Matchup) update(database *model.Database) error { 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 diff --git a/bracket/single_elimination.go b/bracket/single_elimination.go index 1ed0a8b..b5b32af 100644 --- a/bracket/single_elimination.go +++ b/bracket/single_elimination.go @@ -24,50 +24,50 @@ var singleEliminationBracketMatchupTemplates = []matchupTemplate{ matchupKey: newMatchupKey(1, 1), displayNameFormat: "F-${instance}", numWinsToAdvance: 2, - redAllianceSource: newMatchupAllianceSource(2, 1), - blueAllianceSource: newMatchupAllianceSource(2, 2), + redAllianceSource: newWinnerAllianceSource(2, 1), + blueAllianceSource: newWinnerAllianceSource(2, 2), }, { matchupKey: newMatchupKey(2, 1), displayNameFormat: "SF${group}-${instance}", numWinsToAdvance: 2, - redAllianceSource: newMatchupAllianceSource(4, 1), - blueAllianceSource: newMatchupAllianceSource(4, 2), + redAllianceSource: newWinnerAllianceSource(4, 1), + blueAllianceSource: newWinnerAllianceSource(4, 2), }, { matchupKey: newMatchupKey(2, 2), displayNameFormat: "SF${group}-${instance}", numWinsToAdvance: 2, - redAllianceSource: newMatchupAllianceSource(4, 3), - blueAllianceSource: newMatchupAllianceSource(4, 4), + redAllianceSource: newWinnerAllianceSource(4, 3), + blueAllianceSource: newWinnerAllianceSource(4, 4), }, { matchupKey: newMatchupKey(4, 1), displayNameFormat: "QF${group}-${instance}", numWinsToAdvance: 2, - redAllianceSource: newMatchupAllianceSource(8, 1), - blueAllianceSource: newMatchupAllianceSource(8, 2), + redAllianceSource: newWinnerAllianceSource(8, 1), + blueAllianceSource: newWinnerAllianceSource(8, 2), }, { matchupKey: newMatchupKey(4, 2), displayNameFormat: "QF${group}-${instance}", numWinsToAdvance: 2, - redAllianceSource: newMatchupAllianceSource(8, 3), - blueAllianceSource: newMatchupAllianceSource(8, 4), + redAllianceSource: newWinnerAllianceSource(8, 3), + blueAllianceSource: newWinnerAllianceSource(8, 4), }, { matchupKey: newMatchupKey(4, 3), displayNameFormat: "QF${group}-${instance}", numWinsToAdvance: 2, - redAllianceSource: newMatchupAllianceSource(8, 5), - blueAllianceSource: newMatchupAllianceSource(8, 6), + redAllianceSource: newWinnerAllianceSource(8, 5), + blueAllianceSource: newWinnerAllianceSource(8, 6), }, { matchupKey: newMatchupKey(4, 4), displayNameFormat: "QF${group}-${instance}", numWinsToAdvance: 2, - redAllianceSource: newMatchupAllianceSource(8, 7), - blueAllianceSource: newMatchupAllianceSource(8, 8), + redAllianceSource: newWinnerAllianceSource(8, 7), + blueAllianceSource: newWinnerAllianceSource(8, 8), }, { matchupKey: newMatchupKey(8, 1), diff --git a/bracket/single_elimination_test.go b/bracket/single_elimination_test.go index 9bf9964..242ba66 100644 --- a/bracket/single_elimination_test.go +++ b/bracket/single_elimination_test.go @@ -563,4 +563,10 @@ func TestSingleEliminationChangePreviousRoundResult(t *testing.T) { assertMatch(t, matches[6], "F-1", 4, 3) assertMatch(t, matches[7], "F-2", 4, 3) } + + scoreMatch(database, "SF2-3", model.MatchNotPlayed) + assert.Nil(t, bracket.Update(database, &dummyStartTime)) + matches, err = database.GetMatchesByType("elimination") + assert.Nil(t, err) + assert.Equal(t, 6, len(matches)) }