This commit introduces a new Discord event announcer to the backend, allowing for event announcements via Discord. It includes the addition of new environment variables for Discord configuration in `.env.example` and `compose.yaml`. The `main.go` file has been updated to initialize the announcer, and a new `discord` package has been created, containing the announcer logic and tests. Additionally, the service layer has been modified to support bracket draft management, enhancing the overall event workflow. Integration tests have been updated to ensure proper functionality of the new features.
883 lines
29 KiB
Go
883 lines
29 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) 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)
|
|
}
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
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 ""
|
|
}
|