Add Discord event announcement functionality to backend
This commit introduces a new Discord event announcer to the backend, allowing for event announcements via Discord. It includes the addition of new environment variables for Discord configuration in `.env.example` and `compose.yaml`. The `main.go` file has been updated to initialize the announcer, and a new `discord` package has been created, containing the announcer logic and tests. Additionally, the service layer has been modified to support bracket draft management, enhancing the overall event workflow. Integration tests have been updated to ensure proper functionality of the new features.
This commit is contained in:
262
backend/internal/domain/bracket.go
Normal file
262
backend/internal/domain/bracket.go
Normal file
@@ -0,0 +1,262 @@
|
||||
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) Validate() 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 !teams[source.TeamID] || source.MatchID != "" {
|
||||
return fmt.Errorf("%w: unknown team source", 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:
|
||||
return fmt.Errorf("%w: empty bracket slot", ErrInvalid)
|
||||
}
|
||||
}
|
||||
if 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 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})
|
||||
}
|
||||
}
|
||||
}
|
||||
51
backend/internal/domain/bracket_test.go
Normal file
51
backend/internal/domain/bracket_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestThreeTeamBracketResolvesWinnerAndLoserFeeds(t *testing.T) {
|
||||
draft, err := NewBracketDraft("event", []string{"a", "b", "c"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tournament, err := NewGraphTournament("cup", "event", "Cup", *draft)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ready := tournament.ReadyMatches()
|
||||
if len(ready) != 1 || ready[0].TeamAID != "a" || ready[0].TeamBID != "b" {
|
||||
t.Fatalf("unexpected first match: %#v", ready)
|
||||
}
|
||||
if err = tournament.ApplySeries(Series{ID: ready[0].ID, TeamAID: "a", TeamBID: "b", WinnerTeamID: "a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ready = tournament.ReadyMatches()
|
||||
if len(ready) != 1 || ready[0].TeamAID != "b" || ready[0].TeamBID != "c" {
|
||||
t.Fatalf("loser feed was not resolved: %#v", ready)
|
||||
}
|
||||
if err = tournament.ApplySeries(Series{ID: ready[0].ID, TeamAID: "b", TeamBID: "c", WinnerTeamID: "c"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ready = tournament.ReadyMatches()
|
||||
if len(ready) != 1 || ready[0].TeamAID != "a" || ready[0].TeamBID != "c" {
|
||||
t.Fatalf("final feed was not resolved: %#v", ready)
|
||||
}
|
||||
if err = tournament.ApplySeries(Series{ID: ready[0].ID, TeamAID: "a", TeamBID: "c", WinnerTeamID: "c"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tournament.Complete() || tournament.WinnerTeamID != "c" {
|
||||
t.Fatalf("unexpected champion: %q", tournament.WinnerTeamID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBracketRejectsForwardReferencesAndMultipleFinals(t *testing.T) {
|
||||
draft := BracketDraft{
|
||||
EventID: "event", TeamIDs: []string{"a", "b"},
|
||||
Matches: []BracketMatch{
|
||||
{ID: "m1", Round: 0, SlotA: SlotSource{Kind: SlotWinner, MatchID: "m2"}, SlotB: SlotSource{Kind: SlotTeam, TeamID: "a"}},
|
||||
{ID: "m2", Round: 0, Order: 1, SlotA: SlotSource{Kind: SlotTeam, TeamID: "a"}, SlotB: SlotSource{Kind: SlotTeam, TeamID: "b"}},
|
||||
},
|
||||
}
|
||||
if err := draft.Validate(); err == nil {
|
||||
t.Fatal("expected invalid graph")
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const (
|
||||
Balancing EventState = "Balancing"
|
||||
RostersDraft EventState = "RostersDraft"
|
||||
RostersConfirmed EventState = "RostersConfirmed"
|
||||
BracketDraftState EventState = "BracketDraft"
|
||||
Live EventState = "Live"
|
||||
Completed EventState = "Completed"
|
||||
Cancelled EventState = "Cancelled"
|
||||
|
||||
@@ -468,12 +468,14 @@ func (s *Series) recalculateWinner() {
|
||||
}
|
||||
|
||||
type Tournament struct {
|
||||
ID string `json:"id"`
|
||||
EventID string `json:"eventId"`
|
||||
Name string `json:"name"`
|
||||
WinnerTeamID string `json:"winnerTeamId"`
|
||||
TeamIDs []string `json:"teamIds"`
|
||||
Rounds [][]Series `json:"rounds"`
|
||||
ID string `json:"id"`
|
||||
EventID string `json:"eventId"`
|
||||
Name string `json:"name"`
|
||||
WinnerTeamID string `json:"winnerTeamId"`
|
||||
TeamIDs []string `json:"teamIds"`
|
||||
Matches []BracketMatch `json:"matches,omitempty"`
|
||||
Rounds [][]Series `json:"rounds"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
func NewTournament(id, eventID, name string, teams []string) (*Tournament, error) {
|
||||
|
||||
Reference in New Issue
Block a user