Files
mixmaker/backend/internal/domain/series_fsm.go

187 lines
6.2 KiB
Go

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
}