570 lines
17 KiB
Go
570 lines
17 KiB
Go
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
|
|
}
|