package domain import ( "errors" "fmt" "math/rand" "regexp" "slices" "strings" "time" "unicode/utf8" ) 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"` BattleTag string `json:"battleTag"` 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"` } var battleTagPattern = regexp.MustCompile(`^[^#\s]{2,20}#[0-9]{3,12}$`) func (p *Player) ValidateIdentity() error { p.DisplayName = strings.TrimSpace(p.DisplayName) length := utf8.RuneCountInString(p.DisplayName) if length < 2 || length > 32 { return fmt.Errorf("%w: display name must contain 2 to 32 characters", ErrInvalid) } p.BattleTag = strings.TrimSpace(p.BattleTag) if p.BattleTag != "" && !battleTagPattern.MatchString(p.BattleTag) { return fmt.Errorf("%w: BattleTag must have the form Name#digits", ErrInvalid) } return nil } 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 }