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}) } } }