Files
mixmaker/backend/internal/domain/model.go
lemintare ae19c03542
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled
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.
2026-07-19 11:21:11 +03:00

552 lines
16 KiB
Go

package domain
import (
"errors"
"fmt"
"math/rand"
"slices"
"strings"
"time"
)
var (
ErrInvalid = errors.New("invalid input")
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrDraftComplete = errors.New("draft is complete")
)
type ID string
type GlobalRole string
const (
RolePlayer GlobalRole = "player"
RoleModerator GlobalRole = "moderator"
RoleAdmin GlobalRole = "admin"
)
type Account struct {
ID string `json:"id"`
DiscordID string `json:"discordId"`
Username string `json:"username"`
AvatarURL string `json:"avatarUrl"`
Role GlobalRole `json:"role"`
CreatedAt time.Time `json:"createdAt"`
}
func (a Account) IsAdmin() bool { return a.Role == RoleAdmin }
func (a Account) IsStaff() bool { return a.Role == RoleAdmin || a.Role == RoleModerator }
type Ratings struct {
Tank int `json:"tank"`
Damage int `json:"damage"`
Support int `json:"support"`
}
const (
MinCompetitiveRank = 1 // Bronze 5
MaxCompetitiveRank = 40 // Champion 1
)
func (r Ratings) Validate() error {
if r.Tank < MinCompetitiveRank || r.Tank > MaxCompetitiveRank ||
r.Damage < MinCompetitiveRank || r.Damage > MaxCompetitiveRank ||
r.Support < MinCompetitiveRank || r.Support > MaxCompetitiveRank {
return fmt.Errorf("%w: ranks must be between Bronze 5 and Champion 1", ErrInvalid)
}
return nil
}
type Player struct {
ID string `json:"id"`
AccountID string `json:"accountId"`
DisplayName string `json:"displayName"`
Ratings Ratings `json:"ratings"`
PreferredRoles []Role `json:"preferredRoles"`
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
AvoidedPlayerIDs []string `json:"avoidedPlayerIds"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (p Player) ValidatePreferences() error {
if len(p.PreferredPlayerIDs) > 3 {
return fmt.Errorf("%w: at most three preferred teammates are allowed", ErrInvalid)
}
if len(p.AvoidedPlayerIDs) > 3 {
return fmt.Errorf("%w: at most three avoided teammates are allowed", ErrInvalid)
}
seenRoles := map[Role]bool{}
for _, role := range p.PreferredRoles {
if (role != Tank && role != Damage && role != Support) || seenRoles[role] {
return fmt.Errorf("%w: invalid preferred roles", ErrInvalid)
}
seenRoles[role] = true
}
seenPlayers := map[string]bool{}
for _, playerID := range p.PreferredPlayerIDs {
if playerID == "" || playerID == p.ID || seenPlayers[playerID] {
return fmt.Errorf("%w: invalid preferred teammates", ErrInvalid)
}
seenPlayers[playerID] = true
}
seenAvoids := map[string]bool{}
for _, playerID := range p.AvoidedPlayerIDs {
if playerID == "" || playerID == p.ID || seenAvoids[playerID] || seenPlayers[playerID] {
return fmt.Errorf("%w: invalid avoided teammates", ErrInvalid)
}
seenAvoids[playerID] = true
}
return nil
}
type Event struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
StartsAt time.Time `json:"startsAt"`
EndsAt time.Time `json:"endsAt"`
RegistrationDeadline time.Time `json:"registrationDeadline"`
CreatedBy string `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
State EventState `json:"state"`
Version int `json:"version"`
RulesetID string `json:"rulesetId"`
ActiveSeriesID string `json:"activeSeriesId"`
TournamentID string `json:"tournamentId"`
}
func (e Event) Validate() error {
if strings.TrimSpace(e.Name) == "" || e.StartsAt.IsZero() || !e.EndsAt.After(e.StartsAt) ||
e.RegistrationDeadline.IsZero() || e.RegistrationDeadline.After(e.StartsAt) {
return fmt.Errorf("%w: event needs a name and a valid UTC interval", ErrInvalid)
}
return nil
}
type RSVPStatus string
const (
Going RSVPStatus = "Going"
Maybe RSVPStatus = "Maybe"
NotGoing RSVPStatus = "NotGoing"
)
type RSVPSource string
const (
SourcePlayer RSVPSource = "player"
SourceAdmin RSVPSource = "admin"
)
type RSVP struct {
EventID string `json:"eventId"`
PlayerID string `json:"playerId"`
ActorAccountID string `json:"actorAccountId"`
Status RSVPStatus `json:"status"`
Source RSVPSource `json:"source"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (r RSVP) Validate() error {
if r.Status != Going && r.Status != Maybe && r.Status != NotGoing {
return fmt.Errorf("%w: unknown RSVP status", ErrInvalid)
}
if r.EventID == "" || r.PlayerID == "" || r.ActorAccountID == "" {
return fmt.Errorf("%w: RSVP identifiers are required", ErrInvalid)
}
return nil
}
type Slot struct {
PlayerID string `json:"playerId"`
Role Role `json:"role"`
Rating int `json:"rating"`
}
type Team struct {
ID string `json:"id"`
EventID string `json:"eventId"`
Name string `json:"name"`
CaptainPlayerID string `json:"captainPlayerId"`
Slots []Slot `json:"slots"`
}
func (t *Team) AssignCaptain(playerID string) error {
if !slices.ContainsFunc(t.Slots, func(s Slot) bool { return s.PlayerID == playerID }) {
return fmt.Errorf("%w: captain must be on the team", ErrInvalid)
}
t.CaptainPlayerID = playerID
return nil
}
type Role string
const (
Tank Role = "Tank"
Damage Role = "Damage"
Support Role = "Support"
)
type Hero struct {
Name string `json:"name"`
Role Role `json:"role"`
}
type Ruleset struct {
ID string `json:"id"`
Name string `json:"name"`
MapPools [][]string `json:"mapPools"`
Heroes []Hero `json:"heroes"`
HeroBansPerTeam int `json:"heroBansPerTeam"`
BestOf int `json:"bestOf"`
InitialMapBanner int `json:"initialMapBanner"`
InitialHeroBanner int `json:"initialHeroBanner"`
}
func (r Ruleset) Validate() error {
if r.BestOf < 1 || r.BestOf%2 == 0 || r.HeroBansPerTeam < 0 || r.HeroBansPerTeam > 3 || len(r.MapPools) == 0 {
return fmt.Errorf("%w: invalid ruleset", ErrInvalid)
}
for _, pool := range r.MapPools {
if len(pool) == 0 {
return fmt.Errorf("%w: empty map pool", ErrInvalid)
}
}
for _, hero := range r.Heroes {
if strings.TrimSpace(hero.Name) == "" || (hero.Role != Tank && hero.Role != Damage && hero.Role != Support) {
return fmt.Errorf("%w: invalid hero catalog", ErrInvalid)
}
}
return nil
}
type CoinToss struct {
Seed string `json:"seed"`
WinnerTeamID string `json:"winnerTeamId"`
PerformedAt time.Time `json:"performedAt"`
}
func TossCoin(teamA, teamB, seed string, now time.Time) (CoinToss, error) {
if teamA == "" || teamB == "" || teamA == teamB || seed == "" {
return CoinToss{}, fmt.Errorf("%w: two teams and a seed are required", ErrInvalid)
}
var n int64
for _, r := range seed {
n = n*31 + int64(r)
}
winner := teamA
if rand.New(rand.NewSource(n)).Intn(2) == 1 { // deterministic and auditable
winner = teamB
}
return CoinToss{Seed: seed, WinnerTeamID: winner, PerformedAt: now}, nil
}
type DraftAction struct {
TeamID string `json:"teamId"`
Value string `json:"value"`
ActorAccountID string `json:"actorAccountId"`
At time.Time `json:"at"`
}
type MapDraft struct {
Pool []string `json:"pool"`
Banned []string `json:"banned"`
FirstTeamID string `json:"firstTeamId"`
TeamIDs [2]string `json:"teamIds"`
Actions []DraftAction `json:"actions"`
}
func NewMapDraft(pool []string, firstTeam string, teams [2]string) (*MapDraft, error) {
if len(pool) < 1 || teams[0] == teams[1] || (firstTeam != teams[0] && firstTeam != teams[1]) {
return nil, fmt.Errorf("%w: invalid map draft", ErrInvalid)
}
return &MapDraft{Pool: slices.Clone(pool), Banned: []string{}, Actions: []DraftAction{}, FirstTeamID: firstTeam, TeamIDs: teams}, nil
}
func (d *MapDraft) NextTeam() string {
if len(d.Actions)%2 == 0 {
return d.FirstTeamID
}
if d.FirstTeamID == d.TeamIDs[0] {
return d.TeamIDs[1]
}
return d.TeamIDs[0]
}
func (d *MapDraft) Ban(teamID, name, actor string, at time.Time) error {
if len(d.Pool)-len(d.Banned) <= 1 {
return ErrDraftComplete
}
if teamID != d.NextTeam() {
return fmt.Errorf("%w: wrong team turn", ErrConflict)
}
if !slices.Contains(d.Pool, name) || slices.Contains(d.Banned, name) {
return fmt.Errorf("%w: map unavailable", ErrInvalid)
}
d.Banned = append(d.Banned, name)
d.Actions = append(d.Actions, DraftAction{TeamID: teamID, Value: name, ActorAccountID: actor, At: at})
return nil
}
func (d *MapDraft) Selected() (string, bool) {
if len(d.Pool)-len(d.Banned) != 1 {
return "", false
}
for _, m := range d.Pool {
if !slices.Contains(d.Banned, m) {
return m, true
}
}
return "", false
}
type HeroDraft struct {
Heroes []Hero `json:"heroes"`
TeamIDs [2]string `json:"teamIds"`
FirstTeamID string `json:"firstTeamId"`
BansPerTeam int `json:"bansPerTeam"`
SeriesBans map[string][]string `json:"seriesBans"`
CurrentBans []DraftAction `json:"currentBans"`
CurrentRoles map[string][]Role `json:"currentRoles"`
}
func NewHeroDraft(heroes []Hero, teams [2]string, first string, count int, previous map[string][]string) (*HeroDraft, error) {
if teams[0] == teams[1] || (first != teams[0] && first != teams[1]) || count < 0 {
return nil, fmt.Errorf("%w: invalid hero draft", ErrInvalid)
}
if previous == nil {
previous = map[string][]string{}
}
return &HeroDraft{Heroes: slices.Clone(heroes), TeamIDs: teams, FirstTeamID: first, BansPerTeam: count, SeriesBans: previous, CurrentBans: []DraftAction{}, CurrentRoles: map[string][]Role{}}, nil
}
func (d *HeroDraft) NextTeam() string {
if len(d.CurrentBans)%2 == 0 {
return d.FirstTeamID
}
if d.FirstTeamID == d.TeamIDs[0] {
return d.TeamIDs[1]
}
return d.TeamIDs[0]
}
func (d *HeroDraft) Complete() bool { return len(d.CurrentBans) >= d.BansPerTeam*2 }
func (d *HeroDraft) Ban(teamID, heroName, actor string, at time.Time) error {
if d.Complete() {
return ErrDraftComplete
}
if teamID != d.NextTeam() {
return fmt.Errorf("%w: wrong team turn", ErrConflict)
}
var hero *Hero
for i := range d.Heroes {
if d.Heroes[i].Name == heroName {
hero = &d.Heroes[i]
break
}
}
if hero == nil || slices.ContainsFunc(d.CurrentBans, func(a DraftAction) bool { return a.Value == heroName }) {
return fmt.Errorf("%w: hero unavailable", ErrInvalid)
}
if slices.Contains(d.SeriesBans[teamID], heroName) {
return fmt.Errorf("%w: team cannot repeat its own series ban", ErrInvalid)
}
if slices.Contains(d.CurrentRoles[teamID], hero.Role) {
return fmt.Errorf("%w: a team's bans must have different roles", ErrInvalid)
}
d.CurrentBans = append(d.CurrentBans, DraftAction{TeamID: teamID, Value: heroName, ActorAccountID: actor, At: at})
d.CurrentRoles[teamID] = append(d.CurrentRoles[teamID], hero.Role)
d.SeriesBans[teamID] = append(d.SeriesBans[teamID], heroName)
return nil
}
type MapOutcome string
const (
TeamAWin MapOutcome = "TeamAWin"
TeamBWin MapOutcome = "TeamBWin"
Draw MapOutcome = "Draw"
)
type MapResult struct {
MapName string `json:"mapName"`
ActorAccountID string `json:"actorAccountId"`
Outcome MapOutcome `json:"outcome"`
RecordedAt time.Time `json:"recordedAt"`
CorrectionOf int `json:"correctionOf,omitempty"`
}
type Series struct {
ID string `json:"id"`
EventID string `json:"eventId"`
TournamentID string `json:"tournamentId"`
TeamAID string `json:"teamAId"`
TeamBID string `json:"teamBId"`
WinnerTeamID string `json:"winnerTeamId"`
BestOf int `json:"bestOf"`
RulesetID string `json:"rulesetId"`
Phase SeriesPhase `json:"phase"`
CoinToss *CoinToss `json:"coinToss,omitempty"`
MapDraft *MapDraft `json:"mapDraft,omitempty"`
HeroDraft *HeroDraft `json:"heroDraft,omitempty"`
CurrentMap string `json:"currentMap"`
PlayedMaps []string `json:"playedMaps"`
NextMapPickerID string `json:"nextMapPickerId"`
AvailableMaps []string `json:"availableMaps"`
Maps []SeriesMap `json:"maps"`
SeriesBans map[string][]string `json:"seriesBans"`
Audit []SeriesAction `json:"audit"`
Results []MapResult `json:"results"`
Version int `json:"version"`
}
func (s *Series) RecordResult(result MapResult) error {
if s.WinnerTeamID != "" {
return fmt.Errorf("%w: series already complete", ErrConflict)
}
if err := validateMapResult(result); err != nil {
return err
}
result.CorrectionOf = 0
s.Results = append(s.Results, result)
s.Version++
s.recalculateWinner()
return nil
}
func (s *Series) CorrectResult(index int, result MapResult) error {
if index < 0 || index >= len(s.Results) {
return ErrNotFound
}
if err := validateMapResult(result); err != nil {
return err
}
result.CorrectionOf = index + 1
s.Results = append(s.Results, result)
s.Version++
s.recalculateWinner()
return nil
}
func validateMapResult(result MapResult) error {
if result.MapName == "" || (result.Outcome != TeamAWin && result.Outcome != TeamBWin && result.Outcome != Draw) {
return fmt.Errorf("%w: invalid map result", ErrInvalid)
}
return nil
}
func (s *Series) recalculateWinner() {
s.WinnerTeamID = ""
need := s.BestOf/2 + 1
a, b := 0, 0
superseded := make(map[int]bool)
for _, result := range s.Results {
if result.CorrectionOf > 0 {
superseded[result.CorrectionOf] = true
}
}
for i, r := range s.Results {
if superseded[i+1] {
continue
}
if r.Outcome == TeamAWin {
a++
} else if r.Outcome == TeamBWin {
b++
}
}
if a >= need {
s.WinnerTeamID = s.TeamAID
} else if b >= need {
s.WinnerTeamID = s.TeamBID
}
}
type Tournament struct {
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) {
if len(teams) < 2 {
return nil, fmt.Errorf("%w: tournament needs at least two teams", ErrInvalid)
}
t := &Tournament{ID: id, EventID: eventID, Name: name, TeamIDs: slices.Clone(teams)}
bracketSize := 1
for bracketSize < len(teams) {
bracketSize *= 2
}
firstMatchCount := bracketSize / 2
first := make([]Series, firstMatchCount)
for i := range first {
first[i] = Series{ID: fmt.Sprintf("%s-r1-m%d", id, i+1), TournamentID: id, TeamAID: teams[i], BestOf: 3}
}
for i := firstMatchCount; i < len(teams); i++ {
first[i-firstMatchCount].TeamBID = teams[i]
}
for i := range first {
if first[i].TeamBID == "" {
first[i].WinnerTeamID = first[i].TeamAID
}
}
t.Rounds = append(t.Rounds, first)
for matches, round := firstMatchCount/2, 2; matches >= 1; matches, round = matches/2, round+1 {
next := make([]Series, matches)
for i := range next {
next[i] = Series{ID: fmt.Sprintf("%s-r%d-m%d", id, round, i+1), TournamentID: id, BestOf: 3}
}
t.Rounds = append(t.Rounds, next)
}
for match := range first {
if first[match].WinnerTeamID == "" {
continue
}
target := &t.Rounds[1][match/2]
if match%2 == 0 {
target.TeamAID = first[match].WinnerTeamID
} else {
target.TeamBID = first[match].WinnerTeamID
}
}
return t, nil
}
func (t *Tournament) Advance(round, match int) error {
if round < 0 || round >= len(t.Rounds) || match < 0 || match >= len(t.Rounds[round]) {
return ErrNotFound
}
s := t.Rounds[round][match]
if s.WinnerTeamID == "" {
return fmt.Errorf("%w: series is not complete", ErrConflict)
}
if len(t.Rounds[round]) == 1 {
t.WinnerTeamID = s.WinnerTeamID
return nil
}
if len(t.Rounds) == round+1 {
next := make([]Series, len(t.Rounds[round])/2)
for i := range next {
next[i] = Series{ID: fmt.Sprintf("%s-r%d-m%d", t.ID, round+2, i+1), TournamentID: t.ID, BestOf: 3}
}
t.Rounds = append(t.Rounds, next)
}
target := &t.Rounds[round+1][match/2]
if match%2 == 0 {
target.TeamAID = s.WinnerTeamID
} else {
target.TeamBID = s.WinnerTeamID
}
return nil
}