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.
900 lines
30 KiB
Go
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 = ©
|
|
}
|
|
}
|
|
}
|
|
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 ""
|
|
}
|