This commit updates the bracket validation process by introducing a new `ValidateDraft` method, allowing for incomplete edits in the draft state. The existing `Validate` method has been modified to enforce complete validation only upon confirmation. Additionally, new tests have been added to ensure that incomplete drafts are accepted until confirmation, while still rejecting invalid references. Documentation has been updated to reflect these changes in the API and architecture.
276 lines
8.3 KiB
Go
276 lines
8.3 KiB
Go
package domain
|
|
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
"sort"
|
|
)
|
|
|
|
type SlotSourceKind string
|
|
|
|
const (
|
|
SlotTeam SlotSourceKind = "Team"
|
|
SlotWinner SlotSourceKind = "Winner"
|
|
SlotLoser SlotSourceKind = "Loser"
|
|
)
|
|
|
|
type SlotSource struct {
|
|
Kind SlotSourceKind `json:"kind"`
|
|
TeamID string `json:"teamId,omitempty"`
|
|
MatchID string `json:"matchId,omitempty"`
|
|
}
|
|
|
|
type BracketMatch struct {
|
|
ID string `json:"id"`
|
|
Round int `json:"round"`
|
|
Order int `json:"order"`
|
|
SlotA SlotSource `json:"slotA"`
|
|
SlotB SlotSource `json:"slotB"`
|
|
SeriesID string `json:"seriesId,omitempty"`
|
|
TeamAID string `json:"teamAId,omitempty"`
|
|
TeamBID string `json:"teamBId,omitempty"`
|
|
WinnerTeamID string `json:"winnerTeamId,omitempty"`
|
|
Series *Series `json:"series,omitempty"`
|
|
}
|
|
|
|
type BracketDraft struct {
|
|
EventID string `json:"eventId"`
|
|
TeamIDs []string `json:"teamIds"`
|
|
Matches []BracketMatch `json:"matches"`
|
|
Version int `json:"version"`
|
|
Confirmed bool `json:"confirmed"`
|
|
}
|
|
|
|
func NewBracketDraft(eventID string, teamIDs []string) (*BracketDraft, error) {
|
|
if eventID == "" || len(teamIDs) < 2 {
|
|
return nil, fmt.Errorf("%w: bracket needs at least two teams", ErrInvalid)
|
|
}
|
|
draft := &BracketDraft{EventID: eventID, TeamIDs: slices.Clone(teamIDs)}
|
|
add := func(round, order int, a, b SlotSource) string {
|
|
id := fmt.Sprintf("%s-match-%d", eventID, len(draft.Matches)+1)
|
|
draft.Matches = append(draft.Matches, BracketMatch{ID: id, Round: round, Order: order, SlotA: a, SlotB: b})
|
|
return id
|
|
}
|
|
team := func(id string) SlotSource { return SlotSource{Kind: SlotTeam, TeamID: id} }
|
|
winner := func(id string) SlotSource { return SlotSource{Kind: SlotWinner, MatchID: id} }
|
|
loser := func(id string) SlotSource { return SlotSource{Kind: SlotLoser, MatchID: id} }
|
|
if len(teamIDs) == 2 {
|
|
add(0, 0, team(teamIDs[0]), team(teamIDs[1]))
|
|
} else if len(teamIDs) == 3 {
|
|
first := add(0, 0, team(teamIDs[0]), team(teamIDs[1]))
|
|
second := add(1, 0, loser(first), team(teamIDs[2]))
|
|
add(2, 0, winner(first), winner(second))
|
|
} else if len(teamIDs)&(len(teamIDs)-1) == 0 {
|
|
previous := make([]string, 0, len(teamIDs)/2)
|
|
for i := 0; i < len(teamIDs); i += 2 {
|
|
previous = append(previous, add(0, i/2, team(teamIDs[i]), team(teamIDs[i+1])))
|
|
}
|
|
for round := 1; len(previous) > 1; round++ {
|
|
next := make([]string, 0, len(previous)/2)
|
|
for i := 0; i < len(previous); i += 2 {
|
|
next = append(next, add(round, i/2, winner(previous[i]), winner(previous[i+1])))
|
|
}
|
|
previous = next
|
|
}
|
|
} else {
|
|
previous := add(0, 0, team(teamIDs[0]), team(teamIDs[1]))
|
|
for i := 2; i < len(teamIDs); i++ {
|
|
previous = add(i-1, 0, winner(previous), team(teamIDs[i]))
|
|
}
|
|
}
|
|
return draft, draft.Validate()
|
|
}
|
|
|
|
func (d BracketDraft) ValidateDraft() error {
|
|
return d.validate(false)
|
|
}
|
|
|
|
func (d BracketDraft) Validate() error {
|
|
return d.validate(true)
|
|
}
|
|
|
|
func (d BracketDraft) validate(requireComplete bool) error {
|
|
if d.EventID == "" || len(d.TeamIDs) < 2 || len(d.Matches) == 0 {
|
|
return fmt.Errorf("%w: incomplete bracket", ErrInvalid)
|
|
}
|
|
teams := make(map[string]bool, len(d.TeamIDs))
|
|
for _, id := range d.TeamIDs {
|
|
if id == "" || teams[id] {
|
|
return fmt.Errorf("%w: invalid bracket teams", ErrInvalid)
|
|
}
|
|
teams[id] = true
|
|
}
|
|
matches := make(map[string]BracketMatch, len(d.Matches))
|
|
positions := make(map[string]bool, len(d.Matches))
|
|
maxRound := -1
|
|
for _, match := range d.Matches {
|
|
position := fmt.Sprintf("%d:%d", match.Round, match.Order)
|
|
if match.ID == "" || match.Round < 0 || match.Order < 0 || matches[match.ID].ID != "" || positions[position] {
|
|
return fmt.Errorf("%w: invalid bracket match", ErrInvalid)
|
|
}
|
|
matches[match.ID] = match
|
|
positions[position] = true
|
|
if match.Round > maxRound {
|
|
maxRound = match.Round
|
|
}
|
|
}
|
|
finals := 0
|
|
for _, match := range d.Matches {
|
|
if match.Round == maxRound {
|
|
finals++
|
|
}
|
|
for _, source := range []SlotSource{match.SlotA, match.SlotB} {
|
|
switch source.Kind {
|
|
case SlotTeam:
|
|
if source.MatchID != "" || (source.TeamID != "" && !teams[source.TeamID]) {
|
|
return fmt.Errorf("%w: unknown team source", ErrInvalid)
|
|
}
|
|
if requireComplete && source.TeamID == "" {
|
|
return fmt.Errorf("%w: empty bracket slot", ErrInvalid)
|
|
}
|
|
case SlotWinner, SlotLoser:
|
|
upstream, ok := matches[source.MatchID]
|
|
if !ok || upstream.Round >= match.Round || source.TeamID != "" {
|
|
return fmt.Errorf("%w: match source must reference an earlier round", ErrInvalid)
|
|
}
|
|
default:
|
|
if requireComplete || source.Kind != "" || source.TeamID != "" || source.MatchID != "" {
|
|
return fmt.Errorf("%w: empty bracket slot", ErrInvalid)
|
|
}
|
|
}
|
|
}
|
|
if requireComplete && match.SlotA.Kind == SlotTeam && match.SlotB.Kind == SlotTeam && match.SlotA.TeamID == match.SlotB.TeamID {
|
|
return fmt.Errorf("%w: a team cannot play itself", ErrInvalid)
|
|
}
|
|
}
|
|
if requireComplete && finals != 1 {
|
|
return fmt.Errorf("%w: the last round must contain exactly one match", ErrInvalid)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func NewGraphTournament(id, eventID, name string, draft BracketDraft) (*Tournament, error) {
|
|
if err := draft.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
t := &Tournament{ID: id, EventID: eventID, Name: name, TeamIDs: slices.Clone(draft.TeamIDs), Matches: slices.Clone(draft.Matches), Version: 0}
|
|
for i := range t.Matches {
|
|
t.Matches[i].SeriesID, t.Matches[i].TeamAID, t.Matches[i].TeamBID, t.Matches[i].WinnerTeamID, t.Matches[i].Series = "", "", "", "", nil
|
|
}
|
|
if err := t.Resolve(); err != nil {
|
|
return nil, err
|
|
}
|
|
t.BuildRounds()
|
|
return t, nil
|
|
}
|
|
|
|
func (t *Tournament) Resolve() error {
|
|
byID := make(map[string]*BracketMatch, len(t.Matches))
|
|
for i := range t.Matches {
|
|
byID[t.Matches[i].ID] = &t.Matches[i]
|
|
}
|
|
resolve := func(source SlotSource) string {
|
|
if source.Kind == SlotTeam {
|
|
return source.TeamID
|
|
}
|
|
match := byID[source.MatchID]
|
|
if match == nil || match.WinnerTeamID == "" {
|
|
return ""
|
|
}
|
|
if source.Kind == SlotWinner {
|
|
return match.WinnerTeamID
|
|
}
|
|
if match.WinnerTeamID == match.TeamAID {
|
|
return match.TeamBID
|
|
}
|
|
return match.TeamAID
|
|
}
|
|
for i := range t.Matches {
|
|
match := &t.Matches[i]
|
|
match.TeamAID, match.TeamBID = resolve(match.SlotA), resolve(match.SlotB)
|
|
if match.TeamAID != "" && match.TeamAID == match.TeamBID {
|
|
return fmt.Errorf("%w: bracket match resolved to the same team", ErrConflict)
|
|
}
|
|
}
|
|
t.BuildRounds()
|
|
return nil
|
|
}
|
|
|
|
func (t *Tournament) ApplySeries(series Series) error {
|
|
found := false
|
|
for i := range t.Matches {
|
|
if t.Matches[i].ID == series.ID || t.Matches[i].SeriesID == series.ID {
|
|
t.Matches[i].SeriesID = series.ID
|
|
t.Matches[i].TeamAID, t.Matches[i].TeamBID = series.TeamAID, series.TeamBID
|
|
t.Matches[i].WinnerTeamID = series.WinnerTeamID
|
|
copy := series
|
|
t.Matches[i].Series = ©
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return ErrNotFound
|
|
}
|
|
if err := t.Resolve(); err != nil {
|
|
return err
|
|
}
|
|
t.Version++
|
|
if t.Complete() {
|
|
last := slices.MaxFunc(t.Matches, func(a, b BracketMatch) int {
|
|
if a.Round != b.Round {
|
|
return a.Round - b.Round
|
|
}
|
|
return a.Order - b.Order
|
|
})
|
|
t.WinnerTeamID = last.WinnerTeamID
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (t *Tournament) ReadyMatches() []BracketMatch {
|
|
out := make([]BracketMatch, 0)
|
|
for _, match := range t.Matches {
|
|
if match.TeamAID != "" && match.TeamBID != "" && match.SeriesID == "" {
|
|
out = append(out, match)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (t *Tournament) Complete() bool {
|
|
if len(t.Matches) == 0 {
|
|
return false
|
|
}
|
|
for _, match := range t.Matches {
|
|
if match.WinnerTeamID == "" {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (t *Tournament) BuildRounds() {
|
|
if len(t.Matches) == 0 {
|
|
return
|
|
}
|
|
maxRound := 0
|
|
for _, match := range t.Matches {
|
|
if match.Round > maxRound {
|
|
maxRound = match.Round
|
|
}
|
|
}
|
|
t.Rounds = make([][]Series, maxRound+1)
|
|
matches := slices.Clone(t.Matches)
|
|
sort.Slice(matches, func(i, j int) bool {
|
|
return matches[i].Round < matches[j].Round || matches[i].Round == matches[j].Round && matches[i].Order < matches[j].Order
|
|
})
|
|
for _, match := range matches {
|
|
if match.Series != nil {
|
|
t.Rounds[match.Round] = append(t.Rounds[match.Round], *match.Series)
|
|
} else {
|
|
t.Rounds[match.Round] = append(t.Rounds[match.Round], Series{ID: match.ID, EventID: t.EventID, TournamentID: t.ID, TeamAID: match.TeamAID, TeamBID: match.TeamBID, WinnerTeamID: match.WinnerTeamID, BestOf: 3})
|
|
}
|
|
}
|
|
}
|