Files
mixmaker/backend/internal/application/workflow.go
lemintare c3624376b0
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled
Add Discord guild ID configuration and enhance event announcement options
This commit introduces the `DISCORD_GUILD_ID` environment variable to the configuration files, allowing for better integration with Discord for role synchronization. The event announcement functionality has been updated to include an option for the `@everyone` mention, which can be toggled during event creation. The backend logic has been modified to handle this new option, and corresponding updates have been made to the frontend to allow users to control the mention behavior. Additionally, tests have been added to ensure the correct functionality of these features.
2026-07-19 11:34:11 +03:00

900 lines
30 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)
s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionTeardown)
}
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)
if emergency {
s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionReconcile)
}
}
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)
s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionReconcile)
}
return event, err
}
func (s *Service) InitializeBracket(ctx context.Context, actor domain.Account, eventID string, expectedEventVersion int) (domain.BracketDraft, error) {
if !actor.IsStaff() {
return domain.BracketDraft{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil {
return domain.BracketDraft{}, err
}
roster, err := s.Store.GetRoster(ctx, eventID)
if err != nil {
return domain.BracketDraft{}, err
}
if !roster.Confirmed {
return domain.BracketDraft{}, fmt.Errorf("%w: rosters are not confirmed", domain.ErrConflict)
}
teamIDs := make([]string, len(roster.Teams))
for index := range roster.Teams {
teamIDs[index] = roster.Teams[index].ID
}
draft, err := domain.NewBracketDraft(eventID, teamIDs)
if err != nil {
return domain.BracketDraft{}, err
}
old := event.Version
if err = event.Transition([]domain.EventState{domain.RostersConfirmed}, domain.BracketDraftState, expectedEventVersion); err != nil {
return domain.BracketDraft{}, err
}
if _, err = s.Store.SaveBracketDraft(ctx, *draft, -1); err != nil {
return domain.BracketDraft{}, err
}
event.UpdatedAt = s.Now()
if _, err = s.Store.SaveEventWorkflow(ctx, event, old); err == nil {
s.Bus.Publish("event:"+eventID, event)
s.Bus.Publish("bracket:"+eventID, draft)
}
return *draft, err
}
func (s *Service) ResetBracket(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (domain.BracketDraft, error) {
if !actor.IsStaff() {
return domain.BracketDraft{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil || event.State != domain.BracketDraftState {
if err == nil {
err = fmt.Errorf("%w: bracket is not editable", domain.ErrConflict)
}
return domain.BracketDraft{}, err
}
current, err := s.Store.GetBracketDraft(ctx, eventID)
if err != nil || current.Version != expectedVersion {
if err == nil {
err = domain.ErrConflict
}
return current, err
}
draft, err := domain.NewBracketDraft(eventID, current.TeamIDs)
if err != nil {
return current, err
}
draft.Version = current.Version + 1
out, err := s.Store.SaveBracketDraft(ctx, *draft, current.Version)
if err == nil {
s.Bus.Publish("bracket:"+eventID, out)
}
return out, err
}
func (s *Service) UpdateBracket(ctx context.Context, actor domain.Account, eventID string, matches []domain.BracketMatch, expectedVersion int) (domain.BracketDraft, error) {
if !actor.IsStaff() {
return domain.BracketDraft{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil || event.State != domain.BracketDraftState {
if err == nil {
err = fmt.Errorf("%w: bracket is not editable", domain.ErrConflict)
}
return domain.BracketDraft{}, err
}
draft, err := s.Store.GetBracketDraft(ctx, eventID)
if err != nil || draft.Version != expectedVersion {
if err == nil {
err = domain.ErrConflict
}
return draft, err
}
draft.Matches, draft.Confirmed, draft.Version = matches, false, draft.Version+1
if err = draft.Validate(); err != nil {
return draft, err
}
out, err := s.Store.SaveBracketDraft(ctx, draft, expectedVersion)
if err == nil {
s.Bus.Publish("bracket:"+eventID, out)
}
return out, err
}
func (s *Service) ConfirmBracket(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (domain.BracketDraft, error) {
if !actor.IsStaff() {
return domain.BracketDraft{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil || event.State != domain.BracketDraftState {
if err == nil {
err = fmt.Errorf("%w: bracket is not editable", domain.ErrConflict)
}
return domain.BracketDraft{}, err
}
draft, err := s.Store.GetBracketDraft(ctx, eventID)
if err != nil || draft.Version != expectedVersion {
if err == nil {
err = domain.ErrConflict
}
return draft, err
}
if err = draft.Validate(); err != nil {
return draft, err
}
draft.Confirmed, draft.Version = true, draft.Version+1
out, err := s.Store.SaveBracketDraft(ctx, draft, expectedVersion)
if err == nil {
s.Bus.Publish("bracket:"+eventID, out)
}
return out, 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
case domain.BracketDraftState:
previous = domain.RostersConfirmed
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
}
}
if event.State == domain.RostersConfirmed && previous == domain.RostersConfirmed {
_ = s.Store.DeleteBracketDraft(ctx, eventID)
}
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)
if previous == domain.RostersDraft {
s.enqueueDiscordRoleSync(ctx, eventID, DiscordRoleActionTeardown)
}
}
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)
}
draft, err := s.Store.GetBracketDraft(ctx, eventID)
if err != nil {
return ScrimStart{}, err
}
if !draft.Confirmed {
return ScrimStart{}, fmt.Errorf("%w: bracket is not confirmed", domain.ErrConflict)
}
if err = draft.Validate(); err != nil {
return ScrimStart{}, err
}
rules, err := s.Store.GetRuleset(ctx, event.RulesetID)
if err != nil {
return ScrimStart{}, err
}
oldVersion := event.Version
if err = event.Transition([]domain.EventState{domain.BracketDraftState}, domain.Live, expectedVersion); err != nil {
return ScrimStart{}, err
}
result := ScrimStart{}
seriesToSave := make([]domain.Series, 0)
tournament, err := domain.NewGraphTournament(NewID(), eventID, event.Name, draft)
if err != nil {
return ScrimStart{}, err
}
for _, ready := range tournament.ReadyMatches() {
series, createErr := domain.NewSeries(ready.ID, eventID, tournament.ID, [2]string{ready.TeamAID, ready.TeamBID}, rules)
if createErr != nil {
return ScrimStart{}, createErr
}
for index := range tournament.Matches {
if tournament.Matches[index].ID == ready.ID {
tournament.Matches[index].SeriesID = series.ID
tournament.Matches[index].Series = series
}
}
seriesToSave = append(seriesToSave, *series)
if result.Series == nil {
result.Series = series
event.ActiveSeriesID = series.ID
}
}
tournament.BuildRounds()
event.TournamentID = tournament.ID
result.Tournament = tournament
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, player domain.Player, seriesID, seed, actingTeamID string, expectedVersion int) (domain.Series, error) {
return s.mutateSeries(ctx, actor, player, seriesID, actingTeamID, 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, player domain.Player, seriesID, teamID, name string, expectedVersion int) (domain.Series, error) {
return s.mutateSeries(ctx, actor, player, seriesID, teamID, 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, player domain.Player, seriesID, teamID, name string, expectedVersion int) (domain.Series, error) {
return s.mutateSeries(ctx, actor, player, seriesID, teamID, 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, player domain.Player, seriesID, teamID, hero string, expectedVersion int) (domain.Series, error) {
return s.mutateSeries(ctx, actor, player, seriesID, teamID, 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, player domain.Player, seriesID, actingTeamID string, outcome domain.MapOutcome, expectedVersion int) (domain.Series, error) {
out, err := s.mutateSeries(ctx, actor, player, seriesID, actingTeamID, 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)
if getErr == nil {
s.enqueueDiscordRoleSync(ctx, out.EventID, DiscordRoleActionTeardown)
}
}
return out, getErr
}
tournament, err := s.Store.GetTournament(ctx, out.TournamentID)
if err != nil {
return out, err
}
if len(tournament.Matches) > 0 {
return out, s.advanceGraphTournament(ctx, out, tournament)
}
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)
if getErr == nil {
s.enqueueDiscordRoleSync(ctx, out.EventID, DiscordRoleActionTeardown)
}
}
err = getErr
}
return out, err
}
func (s *Service) advanceGraphTournament(ctx context.Context, completed domain.Series, tournament domain.Tournament) error {
event, err := s.Store.GetEvent(ctx, completed.EventID)
if err != nil {
return err
}
rules, err := s.Store.GetRuleset(ctx, event.RulesetID)
if err != nil {
return err
}
for attempt := 0; attempt < 3; attempt++ {
if err = tournament.ApplySeries(completed); err != nil {
return err
}
for _, ready := range tournament.ReadyMatches() {
series, getErr := s.Store.GetSeries(ctx, ready.ID)
if getErr == domain.ErrNotFound {
created, createErr := domain.NewSeries(ready.ID, completed.EventID, tournament.ID, [2]string{ready.TeamAID, ready.TeamBID}, rules)
if createErr != nil {
return createErr
}
series = *created
if _, createErr = s.Store.SaveSeries(ctx, series); createErr != nil && createErr != domain.ErrConflict {
return createErr
}
} else if getErr != nil {
return getErr
}
for index := range tournament.Matches {
if tournament.Matches[index].ID == ready.ID {
tournament.Matches[index].SeriesID = series.ID
copy := series
tournament.Matches[index].Series = &copy
}
}
}
tournament.BuildRounds()
if _, err = s.Store.SaveTournament(ctx, tournament); err != domain.ErrConflict {
break
}
tournament, err = s.Store.GetTournament(ctx, tournament.ID)
if err != nil {
return err
}
}
if err != nil {
return err
}
s.Bus.Publish("tournament:"+tournament.ID, tournament)
s.Bus.Publish("event:"+completed.EventID, tournament)
if tournament.Complete() {
event, err = s.Store.GetEvent(ctx, completed.EventID)
if err != nil {
return err
}
old := event.Version
event.State, event.Version, event.UpdatedAt = domain.Completed, event.Version+1, s.Now()
_, err = s.Store.SaveEventWorkflow(ctx, event, old)
if err == nil {
s.enqueueDiscordRoleSync(ctx, completed.EventID, DiscordRoleActionTeardown)
}
}
return err
}
func (s *Service) mutateSeries(ctx context.Context, actor domain.Account, player domain.Player, seriesID, actingTeamID 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 actingTeamID != series.TeamAID && actingTeamID != series.TeamBID {
return series, domain.ErrForbidden
}
teams, err := s.Store.ListTeams(ctx, series.EventID)
if err != nil {
return series, err
}
authorized := actor.IsStaff()
for _, team := range teams {
if team.ID == actingTeamID && CanActForTeam(actor, player, team) {
authorized = true
break
}
}
if !authorized {
return series, domain.ErrForbidden
}
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)
if series.TournamentID != "" {
s.Bus.Publish("tournament:"+series.TournamentID, series)
}
s.Bus.Publish("event:"+series.EventID, 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 ""
}