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