Files
mixmaker/backend/internal/application/workflow.go

669 lines
22 KiB
Go

package application
import (
"context"
"fmt"
"mixmaker/backend/internal/domain"
)
type BalanceWorkflow struct {
Event domain.Event `json:"event"`
Candidates []domain.BalanceCandidate `json:"candidates"`
}
type ScrimStart struct {
Event domain.Event `json:"event"`
Series *domain.Series `json:"series,omitempty"`
Tournament *domain.Tournament `json:"tournament,omitempty"`
}
func (s *Service) CancelEvent(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (domain.Event, error) {
if !actor.IsStaff() {
return domain.Event{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil {
return domain.Event{}, err
}
if event.State == domain.Completed || event.State == domain.Cancelled {
return domain.Event{}, fmt.Errorf("%w: event cannot be cancelled", domain.ErrConflict)
}
oldVersion := event.Version
event.State, event.Version, event.UpdatedAt = domain.Cancelled, event.Version+1, s.Now()
if oldVersion != expectedVersion {
return domain.Event{}, fmt.Errorf("%w: stale event version", domain.ErrConflict)
}
event, err = s.Store.SaveEventWorkflow(ctx, event, oldVersion)
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "event.cancelled", eventID, event)
s.Bus.Publish("event:"+eventID, event)
s.Bus.Publish("events", event)
}
return event, err
}
func (s *Service) DeleteEvent(ctx context.Context, actor domain.Account, eventID string) error {
if !actor.IsStaff() {
return domain.ErrForbidden
}
if err := s.Store.DeleteEvent(ctx, eventID); err != nil {
return err
}
tombstone := map[string]any{"eventId": eventID, "deleted": true}
s.Bus.Publish("event:"+eventID, tombstone)
s.Bus.Publish("events", tombstone)
return nil
}
func (s *Service) CloseRegistration(ctx context.Context, actor domain.Account, eventID, rulesetID string, expectedVersion int) (domain.Event, error) {
if !actor.IsStaff() {
return domain.Event{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil {
return domain.Event{}, err
}
if _, err = s.Store.GetRuleset(ctx, rulesetID); err != nil {
return domain.Event{}, err
}
oldVersion := event.Version
if err = event.Transition([]domain.EventState{domain.RegistrationOpen}, domain.RegistrationClosed, expectedVersion); err != nil {
return domain.Event{}, err
}
event.RulesetID, event.UpdatedAt = rulesetID, s.Now()
out, err := s.Store.SaveEventWorkflow(ctx, event, oldVersion)
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "event.registration_closed", eventID, out)
s.Bus.Publish("event:"+eventID, out)
}
return out, err
}
func (s *Service) GenerateBalance(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (BalanceWorkflow, error) {
if !actor.IsStaff() {
return BalanceWorkflow{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil {
return BalanceWorkflow{}, err
}
oldVersion := event.Version
if err = event.Transition([]domain.EventState{domain.RegistrationClosed, domain.Balancing}, domain.Balancing, expectedVersion); err != nil {
return BalanceWorkflow{}, err
}
event.UpdatedAt = s.Now()
event, err = s.Store.SaveEventWorkflow(ctx, event, oldVersion)
if err != nil {
return BalanceWorkflow{}, err
}
candidates, err := s.Balance(ctx, actor, eventID)
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "balance.generated", eventID, map[string]int{"count": len(candidates)})
s.Bus.Publish("event:"+eventID, event)
}
return BalanceWorkflow{Event: event, Candidates: candidates}, err
}
func (s *Service) SelectWorkflowBalance(ctx context.Context, actor domain.Account, eventID string, candidate domain.BalanceCandidate, expectedVersion int) (domain.RosterDraft, error) {
if !actor.IsStaff() {
return domain.RosterDraft{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil {
return domain.RosterDraft{}, err
}
oldVersion := event.Version
if err = event.Transition([]domain.EventState{domain.Balancing}, domain.RostersDraft, expectedVersion); err != nil {
return domain.RosterDraft{}, err
}
roster := domain.RosterDraft{EventID: eventID, Teams: candidate.Teams, Reserve: candidate.Reserve, Version: 0}
if err = roster.Validate(false); err != nil {
return domain.RosterDraft{}, err
}
if roster, err = s.Store.SaveRoster(ctx, roster, -1); err != nil {
return domain.RosterDraft{}, err
}
if err = s.Store.SaveTeams(ctx, eventID, roster.Teams); err != nil {
return domain.RosterDraft{}, err
}
event.UpdatedAt = s.Now()
if _, err = s.Store.SaveEventWorkflow(ctx, event, oldVersion); err != nil {
return domain.RosterDraft{}, err
}
_ = s.Store.AppendAudit(ctx, actor.ID, "roster.created", eventID, roster)
s.Bus.Publish("event:"+eventID, roster)
return roster, nil
}
func (s *Service) SwapRoster(ctx context.Context, actor domain.Account, eventID, teamA, playerA, teamB, playerB string, expectedVersion int) (domain.RosterDraft, error) {
if !actor.IsStaff() {
return domain.RosterDraft{}, domain.ErrForbidden
}
roster, err := s.Store.GetRoster(ctx, eventID)
if err != nil {
return roster, err
}
if roster.Version != expectedVersion {
return roster, fmt.Errorf("%w: stale roster version", domain.ErrConflict)
}
roleA, roleB := rosterPlayerRole(roster, teamA, playerA), rosterPlayerRole(roster, teamB, playerB)
players, err := s.Store.ListPlayers(ctx)
if err != nil {
return roster, err
}
ratingAForB, ratingBForA := 0, 0
for _, player := range players {
if player.ID == playerA {
ratingAForB = ratingForRole(player, roleB)
}
if player.ID == playerB {
ratingBForA = ratingForRole(player, roleA)
}
}
if ratingAForB == 0 || ratingBForA == 0 {
return roster, domain.ErrNotFound
}
if err = roster.SwapAcrossRoles(teamA, playerA, teamB, playerB, ratingBForA, ratingAForB); err != nil {
return roster, err
}
roster, err = s.Store.SaveRoster(ctx, roster, expectedVersion)
if err == nil {
_ = s.Store.SaveTeams(ctx, eventID, roster.Teams)
_ = s.Store.AppendAudit(ctx, actor.ID, "roster.swapped", eventID, map[string]string{"playerAId": playerA, "playerBId": playerB})
s.Bus.Publish("event:"+eventID, roster)
}
return roster, err
}
func (s *Service) MoveRosterPlayer(ctx context.Context, actor domain.Account, eventID, fromTeamID, playerID, toTeamID string, role domain.Role, expectedVersion int) (domain.RosterDraft, error) {
if !actor.IsStaff() {
return domain.RosterDraft{}, domain.ErrForbidden
}
roster, err := s.Store.GetRoster(ctx, eventID)
if err != nil {
return roster, err
}
if roster.Version != expectedVersion {
return roster, fmt.Errorf("%w: stale roster version", domain.ErrConflict)
}
players, err := s.Store.ListPlayers(ctx)
if err != nil {
return roster, err
}
targetRating := 0
for _, player := range players {
if player.ID == playerID {
targetRating = ratingForRole(player, role)
break
}
}
if targetRating == 0 {
return roster, domain.ErrNotFound
}
if err = roster.MoveToEmpty(fromTeamID, playerID, toTeamID, role, targetRating); err != nil {
return roster, err
}
return s.saveRosterChange(ctx, actor, roster, expectedVersion, "roster.player_moved")
}
func (s *Service) PlaceReservePlayer(ctx context.Context, actor domain.Account, eventID, teamID, reserveID string, role domain.Role, expectedVersion int) (domain.RosterDraft, error) {
if !actor.IsStaff() {
return domain.RosterDraft{}, domain.ErrForbidden
}
roster, err := s.Store.GetRoster(ctx, eventID)
if err != nil {
return roster, err
}
if roster.Version != expectedVersion {
return roster, fmt.Errorf("%w: stale roster version", domain.ErrConflict)
}
players, err := s.Store.ListPlayers(ctx)
if err != nil {
return roster, err
}
rating := 0
for _, player := range players {
if player.ID == reserveID {
rating = ratingForRole(player, role)
break
}
}
if rating == 0 {
return roster, domain.ErrNotFound
}
if err = roster.PlaceReserve(teamID, role, reserveID, rating); err != nil {
return roster, err
}
return s.saveRosterChange(ctx, actor, roster, expectedVersion, "roster.reserve_placed")
}
func (s *Service) RemoveRosterPlayer(ctx context.Context, actor domain.Account, eventID, teamID, playerID string, expectedVersion int) (domain.RosterDraft, error) {
if !actor.IsStaff() {
return domain.RosterDraft{}, domain.ErrForbidden
}
roster, err := s.Store.GetRoster(ctx, eventID)
if err != nil {
return roster, err
}
if roster.Version != expectedVersion {
return roster, fmt.Errorf("%w: stale roster version", domain.ErrConflict)
}
if err = roster.MoveToReserve(teamID, playerID); err != nil {
return roster, err
}
return s.saveRosterChange(ctx, actor, roster, expectedVersion, "roster.player_removed")
}
func (s *Service) saveRosterChange(ctx context.Context, actor domain.Account, roster domain.RosterDraft, expectedVersion int, action string) (domain.RosterDraft, error) {
out, err := s.Store.SaveRoster(ctx, roster, expectedVersion)
if err == nil {
_ = s.Store.SaveTeams(ctx, roster.EventID, out.Teams)
_ = s.Store.AppendAudit(ctx, actor.ID, action, roster.EventID, out)
s.Bus.Publish("event:"+roster.EventID, out)
}
return out, err
}
func (s *Service) SubstituteRoster(ctx context.Context, actor domain.Account, eventID, teamID, outgoingID, reserveID string, expectedVersion int, emergency bool) (domain.RosterDraft, error) {
if !actor.IsStaff() {
return domain.RosterDraft{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil {
return domain.RosterDraft{}, err
}
if emergency != (event.State == domain.Live) {
return domain.RosterDraft{}, fmt.Errorf("%w: substitution is unavailable in this state", domain.ErrConflict)
}
roster, err := s.Store.GetRoster(ctx, eventID)
if err != nil {
return roster, err
}
if roster.Version != expectedVersion {
return roster, fmt.Errorf("%w: stale roster version", domain.ErrConflict)
}
players, err := s.Store.ListPlayers(ctx)
if err != nil {
return roster, err
}
rating := 0
role := domain.Role("")
for _, team := range roster.Teams {
for _, slot := range team.Slots {
if team.ID == teamID && slot.PlayerID == outgoingID {
role = slot.Role
}
}
}
for _, player := range players {
if player.ID == reserveID {
rating = ratingForRole(player, role)
}
}
if rating == 0 {
return roster, domain.ErrNotFound
}
if emergency {
err = roster.EmergencySubstitute(teamID, outgoingID, reserveID, rating)
} else {
err = roster.Substitute(teamID, outgoingID, reserveID, rating)
}
if err != nil {
return roster, err
}
roster, err = s.Store.SaveRoster(ctx, roster, expectedVersion)
if err == nil {
_ = s.Store.SaveTeams(ctx, eventID, roster.Teams)
action := "roster.substituted"
if emergency {
action = "roster.emergency_substituted"
}
_ = s.Store.AppendAudit(ctx, actor.ID, action, eventID, map[string]string{"outgoingPlayerId": outgoingID, "incomingPlayerId": reserveID})
s.Bus.Publish("event:"+eventID, roster)
}
return roster, err
}
func (s *Service) SetRosterCaptain(ctx context.Context, actor domain.Account, eventID, teamID, playerID string, expectedVersion int) (domain.RosterDraft, error) {
if !actor.IsStaff() {
return domain.RosterDraft{}, domain.ErrForbidden
}
roster, err := s.Store.GetRoster(ctx, eventID)
if err != nil {
return roster, err
}
if roster.Confirmed || roster.Version != expectedVersion {
return roster, domain.ErrConflict
}
found := false
for i := range roster.Teams {
if roster.Teams[i].ID == teamID {
if err = roster.Teams[i].AssignCaptain(playerID); err != nil {
return roster, err
}
found = true
}
}
if !found {
return roster, domain.ErrNotFound
}
roster.Version++
roster, err = s.Store.SaveRoster(ctx, roster, expectedVersion)
if err == nil {
_ = s.Store.SaveTeams(ctx, eventID, roster.Teams)
_ = s.Store.AppendAudit(ctx, actor.ID, "roster.captain_assigned", eventID, map[string]string{"teamId": teamID, "playerId": playerID})
s.Bus.Publish("event:"+eventID, roster)
}
return roster, err
}
func (s *Service) ConfirmRosters(ctx context.Context, actor domain.Account, eventID string, expectedEventVersion, expectedRosterVersion int) (domain.Event, error) {
if !actor.IsStaff() {
return domain.Event{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil {
return event, err
}
roster, err := s.Store.GetRoster(ctx, eventID)
if err != nil {
return event, err
}
if roster.Version != expectedRosterVersion {
return event, domain.ErrConflict
}
if err = roster.Validate(true); err != nil {
return event, err
}
oldEventVersion := event.Version
if err = event.Transition([]domain.EventState{domain.RostersDraft}, domain.RostersConfirmed, expectedEventVersion); err != nil {
return event, err
}
roster.Confirmed, roster.Version = true, roster.Version+1
if _, err = s.Store.SaveRoster(ctx, roster, expectedRosterVersion); err != nil {
return event, err
}
event.UpdatedAt = s.Now()
event, err = s.Store.SaveEventWorkflow(ctx, event, oldEventVersion)
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "roster.confirmed", eventID, roster)
s.Bus.Publish("event:"+eventID, event)
}
return event, err
}
func (s *Service) RevertWorkflowStage(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (domain.Event, error) {
if !actor.IsStaff() {
return domain.Event{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil {
return event, err
}
oldVersion := event.Version
var previous domain.EventState
switch event.State {
case domain.RegistrationClosed:
previous = domain.RegistrationOpen
case domain.Balancing:
previous = domain.RegistrationClosed
case domain.RostersDraft:
previous = domain.Balancing
case domain.RostersConfirmed:
previous = domain.RostersDraft
default:
return event, fmt.Errorf("%w: this workflow stage cannot be reverted", domain.ErrConflict)
}
if err = event.Transition([]domain.EventState{event.State}, previous, expectedVersion); err != nil {
return event, err
}
if previous == domain.Balancing {
if err = s.Store.ResetRoster(ctx, eventID); err != nil {
return event, err
}
}
if previous == domain.RostersDraft {
roster, rosterErr := s.Store.GetRoster(ctx, eventID)
if rosterErr != nil {
return event, rosterErr
}
rosterVersion := roster.Version
roster.Confirmed = false
roster.Version++
if _, rosterErr = s.Store.SaveRoster(ctx, roster, rosterVersion); rosterErr != nil {
return event, rosterErr
}
}
event.UpdatedAt = s.Now()
event, err = s.Store.SaveEventWorkflow(ctx, event, oldVersion)
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "workflow.reverted", eventID, map[string]domain.EventState{"state": previous})
s.Bus.Publish("event:"+eventID, event)
}
return event, err
}
func (s *Service) StartScrim(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (ScrimStart, error) {
if !actor.IsStaff() {
return ScrimStart{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil {
return ScrimStart{}, err
}
roster, err := s.Store.GetRoster(ctx, eventID)
if err != nil {
return ScrimStart{}, err
}
if !roster.Confirmed {
return ScrimStart{}, fmt.Errorf("%w: rosters are not confirmed", domain.ErrConflict)
}
rules, err := s.Store.GetRuleset(ctx, event.RulesetID)
if err != nil {
return ScrimStart{}, err
}
oldVersion := event.Version
if err = event.Transition([]domain.EventState{domain.RostersConfirmed}, domain.Live, expectedVersion); err != nil {
return ScrimStart{}, err
}
result := ScrimStart{}
seriesToSave := make([]domain.Series, 0)
teamIDs := make([]string, len(roster.Teams))
for i := range roster.Teams {
teamIDs[i] = roster.Teams[i].ID
}
if len(teamIDs) == 2 {
series, createErr := domain.NewSeries(NewID(), eventID, "", [2]string{teamIDs[0], teamIDs[1]}, rules)
if createErr != nil {
return ScrimStart{}, createErr
}
seriesToSave = append(seriesToSave, *series)
event.ActiveSeriesID = series.ID
result.Series = series
} else if len(teamIDs) >= 4 {
tournament, createErr := domain.NewTournament(NewID(), eventID, event.Name, teamIDs)
if createErr != nil {
return ScrimStart{}, createErr
}
for round := range tournament.Rounds {
for match := range tournament.Rounds[round] {
base := tournament.Rounds[round][match]
if base.TeamAID == "" || base.TeamBID == "" {
continue
}
series, seriesErr := domain.NewSeries(base.ID, eventID, tournament.ID, [2]string{base.TeamAID, base.TeamBID}, rules)
if seriesErr != nil {
return ScrimStart{}, seriesErr
}
tournament.Rounds[round][match] = *series
seriesToSave = append(seriesToSave, *series)
}
}
event.TournamentID = tournament.ID
result.Tournament = tournament
} else {
return ScrimStart{}, fmt.Errorf("%w: scrim needs two or at least four teams", domain.ErrInvalid)
}
event.UpdatedAt = s.Now()
err = s.Store.StartScrim(ctx, event, oldVersion, seriesToSave, result.Tournament)
if err == nil {
event, err = s.Store.GetEvent(ctx, event.ID)
}
result.Event = event
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, "scrim.started", eventID, result)
s.Bus.Publish("event:"+eventID, result)
}
return result, err
}
func (s *Service) TossSeriesCoin(ctx context.Context, actor domain.Account, seriesID, seed string, expectedVersion int) (domain.Series, error) {
return s.mutateSeries(ctx, actor, seriesID, expectedVersion, "series.coin_tossed", func(series *domain.Series, rules domain.Ruleset) error {
return series.Toss(seed, actor.ID, s.Now(), rules)
})
}
func (s *Service) BanSeriesMap(ctx context.Context, actor domain.Account, seriesID, teamID, name string, expectedVersion int) (domain.Series, error) {
return s.mutateSeries(ctx, actor, seriesID, expectedVersion, "series.map_banned", func(series *domain.Series, rules domain.Ruleset) error {
return series.BanMap(teamID, name, actor.ID, s.Now(), rules)
})
}
func (s *Service) PickSeriesMap(ctx context.Context, actor domain.Account, seriesID, teamID, name string, expectedVersion int) (domain.Series, error) {
return s.mutateSeries(ctx, actor, seriesID, expectedVersion, "series.map_picked", func(series *domain.Series, rules domain.Ruleset) error {
return series.PickMap(teamID, name, actor.ID, s.Now(), rules)
})
}
func (s *Service) BanSeriesHero(ctx context.Context, actor domain.Account, seriesID, teamID, hero string, expectedVersion int) (domain.Series, error) {
return s.mutateSeries(ctx, actor, seriesID, expectedVersion, "series.hero_banned", func(series *domain.Series, _ domain.Ruleset) error {
return series.BanHero(teamID, hero, actor.ID, s.Now())
})
}
func (s *Service) RecordSeriesResult(ctx context.Context, actor domain.Account, seriesID string, outcome domain.MapOutcome, expectedVersion int) (domain.Series, error) {
out, err := s.mutateSeries(ctx, actor, seriesID, expectedVersion, "series.map_recorded", func(series *domain.Series, rules domain.Ruleset) error {
return series.RecordCurrentMap(outcome, actor.ID, s.Now(), rules)
})
if err != nil || out.WinnerTeamID == "" {
return out, err
}
if out.TournamentID == "" {
event, getErr := s.Store.GetEvent(ctx, out.EventID)
if getErr == nil {
old := event.Version
event.State, event.Version, event.UpdatedAt = domain.Completed, event.Version+1, s.Now()
_, getErr = s.Store.SaveEventWorkflow(ctx, event, old)
}
return out, getErr
}
tournament, err := s.Store.GetTournament(ctx, out.TournamentID)
if err != nil {
return out, err
}
for round := range tournament.Rounds {
for match := range tournament.Rounds[round] {
if tournament.Rounds[round][match].ID != out.ID {
continue
}
tournament.Rounds[round][match] = out
if err = tournament.Advance(round, match); err != nil {
return out, err
}
if round+1 < len(tournament.Rounds) {
next := tournament.Rounds[round+1][match/2]
if next.TeamAID != "" && next.TeamBID != "" {
existing, existingErr := s.Store.GetSeries(ctx, next.ID)
if existingErr == nil {
tournament.Rounds[round+1][match/2] = existing
} else if existingErr == domain.ErrNotFound {
event, _ := s.Store.GetEvent(ctx, out.EventID)
rules, _ := s.Store.GetRuleset(ctx, event.RulesetID)
series, createErr := domain.NewSeries(next.ID, out.EventID, tournament.ID, [2]string{next.TeamAID, next.TeamBID}, rules)
if createErr != nil {
return out, createErr
}
tournament.Rounds[round+1][match/2] = *series
_, err = s.Store.SaveSeries(ctx, *series)
} else {
err = existingErr
}
}
}
}
}
if err == nil {
_, err = s.Store.SaveTournament(ctx, tournament)
s.Bus.Publish("tournament:"+tournament.ID, tournament)
s.Bus.Publish("event:"+out.EventID, tournament)
}
if err == nil && tournament.WinnerTeamID != "" {
event, getErr := s.Store.GetEvent(ctx, out.EventID)
if getErr == nil {
old := event.Version
event.State, event.Version, event.UpdatedAt = domain.Completed, event.Version+1, s.Now()
_, getErr = s.Store.SaveEventWorkflow(ctx, event, old)
}
err = getErr
}
return out, err
}
func (s *Service) mutateSeries(ctx context.Context, actor domain.Account, seriesID string, expectedVersion int, action string, mutation func(*domain.Series, domain.Ruleset) error) (domain.Series, error) {
series, err := s.Store.GetSeries(ctx, seriesID)
if err != nil {
return series, err
}
event, err := s.Store.GetEvent(ctx, series.EventID)
if err != nil {
return series, err
}
if event.State != domain.Live {
return series, fmt.Errorf("%w: event is not live", domain.ErrConflict)
}
if series.Version != expectedVersion {
return series, fmt.Errorf("%w: stale series version", domain.ErrConflict)
}
rules, err := s.Store.GetRuleset(ctx, series.RulesetID)
if err != nil {
return series, err
}
if err = mutation(&series, rules); err != nil {
return series, err
}
series, err = s.Store.SaveSeries(ctx, series)
if err == nil {
_ = s.Store.AppendAudit(ctx, actor.ID, action, seriesID, series)
s.Bus.Publish("series:"+seriesID, series)
}
return series, err
}
func ratingForRole(player domain.Player, role domain.Role) int {
switch role {
case domain.Tank:
return player.Ratings.Tank
case domain.Damage:
return player.Ratings.Damage
case domain.Support:
return player.Ratings.Support
default:
return 0
}
}
func rosterPlayerRole(roster domain.RosterDraft, teamID, playerID string) domain.Role {
for _, team := range roster.Teams {
if team.ID != teamID {
continue
}
for _, slot := range team.Slots {
if slot.PlayerID == playerID {
return slot.Role
}
}
}
return ""
}