package domain import ( "fmt" "slices" "time" ) type SeriesPhase string const ( CoinTossPending SeriesPhase = "CoinTossPending" MapBanPhase SeriesPhase = "MapBan" MapPickPhase SeriesPhase = "MapPick" HeroBanPhase SeriesPhase = "HeroBan" PlayingPhase SeriesPhase = "Playing" SeriesComplete SeriesPhase = "Completed" ) type SeriesAction struct { Kind string `json:"kind"` Summary string `json:"summary"` TeamID string `json:"teamId,omitempty"` ActorAccountID string `json:"actorAccountId"` At time.Time `json:"at"` } type SeriesMap struct { Number int `json:"number"` Name string `json:"name"` HeroBans []DraftAction `json:"heroBans"` Result *MapResult `json:"result,omitempty"` } func NewSeries(id, eventID, tournamentID string, teams [2]string, rules Ruleset) (*Series, error) { if id == "" || eventID == "" || teams[0] == "" || teams[1] == "" || teams[0] == teams[1] { return nil, fmt.Errorf("%w: invalid series", ErrInvalid) } if err := rules.Validate(); err != nil { return nil, err } return &Series{ ID: id, EventID: eventID, TournamentID: tournamentID, TeamAID: teams[0], TeamBID: teams[1], BestOf: rules.BestOf, RulesetID: rules.ID, Phase: CoinTossPending, SeriesBans: map[string][]string{}, }, nil } func (s *Series) Toss(seed, actor string, now time.Time, rules Ruleset) error { if s.Phase != CoinTossPending { return fmt.Errorf("%w: coin toss is unavailable", ErrConflict) } toss, err := TossCoin(s.TeamAID, s.TeamBID, seed, now) if err != nil { return err } s.CoinToss = &toss first := toss.WinnerTeamID if rules.InitialMapBanner == 1 { first = s.otherTeam(first) } draft, err := NewMapDraft(s.availablePool(rules, 0), first, [2]string{s.TeamAID, s.TeamBID}) if err != nil { return err } s.MapDraft, s.Phase = draft, MapBanPhase s.Version++ s.Audit = append(s.Audit, SeriesAction{Kind: "coin_toss", Summary: "Coin toss won by " + toss.WinnerTeamID, TeamID: toss.WinnerTeamID, ActorAccountID: actor, At: now}) return nil } func (s *Series) BanMap(teamID, name, actor string, now time.Time, rules Ruleset) error { if s.Phase != MapBanPhase || s.MapDraft == nil { return fmt.Errorf("%w: map ban is unavailable", ErrConflict) } if err := s.MapDraft.Ban(teamID, name, actor, now); err != nil { return err } s.Version++ s.Audit = append(s.Audit, SeriesAction{Kind: "map_ban", Summary: name + " banned", TeamID: teamID, ActorAccountID: actor, At: now}) selected, complete := s.MapDraft.Selected() if !complete { return nil } return s.startHeroDraft(selected, actor, now, rules) } func (s *Series) PickMap(teamID, name, actor string, now time.Time, rules Ruleset) error { if s.Phase != MapPickPhase { return fmt.Errorf("%w: map pick is unavailable", ErrConflict) } if teamID != s.NextMapPickerID { return fmt.Errorf("%w: only the previous map loser can pick", ErrConflict) } if !slices.Contains(s.AvailableMaps, name) { return fmt.Errorf("%w: map unavailable", ErrInvalid) } s.Version++ s.Audit = append(s.Audit, SeriesAction{Kind: "map_selected", Summary: name + " picked", TeamID: teamID, ActorAccountID: actor, At: now}) return s.startHeroDraft(name, actor, now, rules) } func (s *Series) startHeroDraft(selected, actor string, now time.Time, rules Ruleset) error { s.CurrentMap = selected first := s.otherTeam(s.CoinToss.WinnerTeamID) if rules.InitialHeroBanner == 0 { first = s.CoinToss.WinnerTeamID } draft, err := NewHeroDraft(rules.Heroes, [2]string{s.TeamAID, s.TeamBID}, first, rules.HeroBansPerTeam, s.SeriesBans) if err != nil { return err } s.HeroDraft, s.Phase = draft, HeroBanPhase s.NextMapPickerID, s.AvailableMaps = "", nil s.Maps = append(s.Maps, SeriesMap{Number: len(s.Maps) + 1, Name: selected}) if len(s.Audit) == 0 || s.Audit[len(s.Audit)-1].Kind != "map_selected" { s.Audit = append(s.Audit, SeriesAction{Kind: "map_selected", Summary: selected + " selected", ActorAccountID: actor, At: now}) } return nil } func (s *Series) BanHero(teamID, hero, actor string, now time.Time) error { if s.Phase != HeroBanPhase || s.HeroDraft == nil { return fmt.Errorf("%w: hero ban is unavailable", ErrConflict) } if err := s.HeroDraft.Ban(teamID, hero, actor, now); err != nil { return err } s.Version++ s.Maps[len(s.Maps)-1].HeroBans = slices.Clone(s.HeroDraft.CurrentBans) s.Audit = append(s.Audit, SeriesAction{Kind: "hero_ban", Summary: hero + " banned", TeamID: teamID, ActorAccountID: actor, At: now}) if s.HeroDraft.Complete() { s.SeriesBans = s.HeroDraft.SeriesBans s.Phase = PlayingPhase } return nil } func (s *Series) RecordCurrentMap(outcome MapOutcome, actor string, now time.Time, rules Ruleset) error { if s.Phase != PlayingPhase || s.CurrentMap == "" { return fmt.Errorf("%w: map result is unavailable", ErrConflict) } result := MapResult{MapName: s.CurrentMap, Outcome: outcome, ActorAccountID: actor, RecordedAt: now} if err := s.RecordResult(result); err != nil { return err } s.PlayedMaps = append(s.PlayedMaps, s.CurrentMap) s.Maps[len(s.Maps)-1].Result = &result s.Audit = append(s.Audit, SeriesAction{Kind: "result", Summary: s.CurrentMap + ": " + string(outcome), ActorAccountID: actor, At: now}) if s.WinnerTeamID != "" { s.Phase = SeriesComplete return nil } poolIndex := len(s.Maps) if poolIndex >= len(rules.MapPools) { poolIndex = len(rules.MapPools) - 1 } switch outcome { case TeamAWin: s.NextMapPickerID = s.TeamBID case TeamBWin: s.NextMapPickerID = s.TeamAID default: // A draw has no loser, so the initial coin-toss loser receives the pick. s.NextMapPickerID = s.otherTeam(s.CoinToss.WinnerTeamID) } s.AvailableMaps = s.availablePool(rules, poolIndex) if len(s.AvailableMaps) == 0 { return fmt.Errorf("%w: no unplayed maps remain in pool", ErrInvalid) } s.MapDraft, s.HeroDraft, s.CurrentMap, s.Phase = nil, nil, "", MapPickPhase return nil } func (s Series) availablePool(rules Ruleset, index int) []string { pool := slices.Clone(rules.MapPools[index]) return slices.DeleteFunc(pool, func(name string) bool { return slices.Contains(s.PlayedMaps, name) }) } func (s Series) otherTeam(teamID string) string { if teamID == s.TeamAID { return s.TeamBID } return s.TeamAID }