Refactor series handling in backend to include player context in Toss, Ban, Pick, and Record actions. Update service methods to accept player parameters, enhancing authorization checks and ensuring proper team actions. Modify integration tests to reflect new method signatures and improve tournament hydration logic in the store. Enhance frontend components to support new series features and improve user experience with live match navigation.
This commit is contained in:
@@ -207,7 +207,8 @@ func (s *Server) tossSeriesCoin(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
out, err := s.service.TossSeriesCoin(r.Context(), who(r).account, series.ID, in.Seed, in.ExpectedVersion)
|
||||
id := who(r)
|
||||
out, err := s.service.TossSeriesCoin(r.Context(), id.account, id.player, series.ID, in.Seed, in.ActingTeamID, in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -228,7 +229,8 @@ func (s *Server) banSeriesMap(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
out, err := s.service.BanSeriesMap(r.Context(), who(r).account, series.ID, in.TeamID, in.Map, in.ExpectedVersion)
|
||||
id := who(r)
|
||||
out, err := s.service.BanSeriesMap(r.Context(), id.account, id.player, series.ID, in.TeamID, in.Map, in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -249,7 +251,8 @@ func (s *Server) pickSeriesMap(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
out, err := s.service.PickSeriesMap(r.Context(), who(r).account, series.ID, in.TeamID, in.Map, in.ExpectedVersion)
|
||||
id := who(r)
|
||||
out, err := s.service.PickSeriesMap(r.Context(), id.account, id.player, series.ID, in.TeamID, in.Map, in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -270,7 +273,8 @@ func (s *Server) banSeriesHero(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
out, err := s.service.BanSeriesHero(r.Context(), who(r).account, series.ID, in.TeamID, in.Hero, in.ExpectedVersion)
|
||||
id := who(r)
|
||||
out, err := s.service.BanSeriesHero(r.Context(), id.account, id.player, series.ID, in.TeamID, in.Hero, in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -291,6 +295,7 @@ func (s *Server) recordSeriesResult(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
out, err := s.service.RecordSeriesResult(r.Context(), who(r).account, series.ID, in.Outcome, in.ExpectedVersion)
|
||||
id := who(r)
|
||||
out, err := s.service.RecordSeriesResult(r.Context(), id.account, id.player, series.ID, in.ActingTeamID, in.Outcome, in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -612,6 +612,9 @@ func (s *Store) GetTournament(ctx context.Context, id string) (domain.Tournament
|
||||
if err == nil {
|
||||
err = json.Unmarshal(body, &out)
|
||||
}
|
||||
if err == nil {
|
||||
err = s.hydrateTournament(ctx, &out)
|
||||
}
|
||||
return out, mapError(err)
|
||||
}
|
||||
|
||||
@@ -622,9 +625,43 @@ func (s *Store) GetTournamentByEvent(ctx context.Context, eventID string) (domai
|
||||
if err == nil {
|
||||
err = json.Unmarshal(body, &out)
|
||||
}
|
||||
if err == nil {
|
||||
err = s.hydrateTournament(ctx, &out)
|
||||
}
|
||||
return out, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) hydrateTournament(ctx context.Context, tournament *domain.Tournament) error {
|
||||
rows, err := s.pool.Query(ctx, `SELECT body FROM series WHERE tournament_id=$1`, tournament.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
live := make(map[string]domain.Series)
|
||||
for rows.Next() {
|
||||
var body []byte
|
||||
var series domain.Series
|
||||
if err := rows.Scan(&body); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(body, &series); err != nil {
|
||||
return err
|
||||
}
|
||||
live[series.ID] = series
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for round := range tournament.Rounds {
|
||||
for match := range tournament.Rounds[round] {
|
||||
if series, ok := live[tournament.Rounds[round][match].ID]; ok {
|
||||
tournament.Rounds[round][match] = series
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveDraft(ctx context.Context, id, kind string, value any, expectedVersion int) error {
|
||||
body, _ := json.Marshal(value)
|
||||
if expectedVersion < 0 {
|
||||
|
||||
@@ -135,20 +135,20 @@ func TestFullScrimPipeline(t *testing.T) {
|
||||
t.Fatalf("start failed: %#v, %v", started, err)
|
||||
}
|
||||
seriesIDs = append(seriesIDs, started.Series.ID)
|
||||
series, err := service.TossSeriesCoin(ctx, admin, started.Series.ID, "integration-seed", started.Series.Version)
|
||||
series, err := service.TossSeriesCoin(ctx, admin, domain.Player{}, started.Series.ID, "integration-seed", started.Series.TeamAID, started.Series.Version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for mapNumber := 0; mapNumber < 2; mapNumber++ {
|
||||
if series.Phase == domain.MapPickPhase {
|
||||
series, err = service.PickSeriesMap(ctx, admin, series.ID, series.NextMapPickerID, series.AvailableMaps[0], series.Version)
|
||||
series, err = service.PickSeriesMap(ctx, admin, domain.Player{}, series.ID, series.NextMapPickerID, series.AvailableMaps[0], series.Version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for series.Phase != domain.HeroBanPhase {
|
||||
name := firstUnbanned(series.MapDraft.Pool, series.MapDraft.Banned)
|
||||
series, err = service.BanSeriesMap(ctx, admin, series.ID, series.MapDraft.NextTeam(), name, series.Version)
|
||||
series, err = service.BanSeriesMap(ctx, admin, domain.Player{}, series.ID, series.MapDraft.NextTeam(), name, series.Version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -156,12 +156,12 @@ func TestFullScrimPipeline(t *testing.T) {
|
||||
for series.Phase != domain.PlayingPhase {
|
||||
teamID := series.HeroDraft.NextTeam()
|
||||
hero := firstAvailableHero(series.HeroDraft, teamID)
|
||||
series, err = service.BanSeriesHero(ctx, admin, series.ID, teamID, hero, series.Version)
|
||||
series, err = service.BanSeriesHero(ctx, admin, domain.Player{}, series.ID, teamID, hero, series.Version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
series, err = service.RecordSeriesResult(ctx, admin, series.ID, domain.TeamAWin, series.Version)
|
||||
series, err = service.RecordSeriesResult(ctx, admin, domain.Player{}, series.ID, series.TeamAID, domain.TeamAWin, series.Version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -175,6 +175,97 @@ func TestFullScrimPipeline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not configured")
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := Migrate(ctx, databaseURL, "../../../migrations"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := Open(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
accountID, eventID, tournamentID := "parallel-admin-"+suffix, "parallel-event-"+suffix, "parallel-tournament-"+suffix
|
||||
_, err = store.pool.Exec(ctx, `INSERT INTO accounts(id,discord_id,username,avatar_url,role,created_at) VALUES($1,$1,'Parallel Admin','','admin',now())`, accountID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM series WHERE tournament_id=$1`, tournamentID)
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM events WHERE id=$1`, eventID)
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, accountID)
|
||||
}()
|
||||
service := application.New(store, discardPublisher{})
|
||||
event, err := service.CreateEvent(ctx, domain.Account{ID: accountID, Role: domain.RoleAdmin}, domain.Event{
|
||||
ID: eventID, Name: "Parallel tournament", StartsAt: time.Now().Add(time.Hour), EndsAt: time.Now().Add(3 * time.Hour),
|
||||
RegistrationDeadline: time.Now(), RulesetID: "standard-control-hybrid-control",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventID = event.ID
|
||||
rules, err := store.GetRuleset(ctx, event.RulesetID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tournament, err := domain.NewTournament(tournamentID, eventID, event.Name, []string{"team-a", "team-b", "team-c", "team-d"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = store.SaveTournament(ctx, *tournament); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for match := range tournament.Rounds[0] {
|
||||
slot := tournament.Rounds[0][match]
|
||||
series, createErr := domain.NewSeries(slot.ID, eventID, tournamentID, [2]string{slot.TeamAID, slot.TeamBID}, rules)
|
||||
if createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
}
|
||||
if match == 0 {
|
||||
if createErr = series.Toss("parallel-seed", accountID, time.Now().UTC(), rules); createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
}
|
||||
}
|
||||
if _, createErr = store.SaveSeries(ctx, *series); createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
}
|
||||
}
|
||||
hydrated, err := store.GetTournamentByEvent(ctx, eventID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hydrated.Rounds[0][0].Phase != domain.MapBanPhase || hydrated.Rounds[0][1].Phase != domain.CoinTossPending {
|
||||
t.Fatalf("parallel series were not hydrated independently: %s / %s", hydrated.Rounds[0][0].Phase, hydrated.Rounds[0][1].Phase)
|
||||
}
|
||||
for match := range hydrated.Rounds[0] {
|
||||
hydrated.Rounds[0][match].WinnerTeamID = hydrated.Rounds[0][match].TeamAID
|
||||
hydrated.Rounds[0][match].Phase = domain.SeriesComplete
|
||||
if err = hydrated.Advance(0, match); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err = store.SaveTournament(ctx, hydrated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
finalSlot := hydrated.Rounds[1][0]
|
||||
final, err := domain.NewSeries(finalSlot.ID, eventID, tournamentID, [2]string{finalSlot.TeamAID, finalSlot.TeamBID}, rules)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = store.SaveSeries(ctx, *final); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hydrated, err = store.GetTournamentByEvent(ctx, eventID)
|
||||
if err != nil || hydrated.Rounds[1][0].Phase != domain.CoinTossPending {
|
||||
t.Fatalf("final was not persisted and hydrated: phase=%s err=%v", hydrated.Rounds[1][0].Phase, err)
|
||||
}
|
||||
}
|
||||
|
||||
func firstUnbanned(pool, banned []string) string {
|
||||
for _, name := range pool {
|
||||
found := false
|
||||
|
||||
@@ -148,9 +148,7 @@ func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event d
|
||||
if !actor.IsStaff() {
|
||||
return domain.Event{}, domain.ErrForbidden
|
||||
}
|
||||
if event.RegistrationDeadline.IsZero() {
|
||||
event.RegistrationDeadline = event.StartsAt
|
||||
}
|
||||
event.RegistrationDeadline = event.StartsAt
|
||||
event.ID, event.CreatedBy, event.CreatedAt, event.UpdatedAt = NewID(), actor.ID, s.Now(), s.Now()
|
||||
event.State, event.Version = domain.RegistrationOpen, 0
|
||||
if event.RulesetID == "" {
|
||||
@@ -174,9 +172,7 @@ func (s *Service) UpdateEvent(ctx context.Context, actor domain.Account, eventID
|
||||
if err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
if changes.RegistrationDeadline.IsZero() {
|
||||
changes.RegistrationDeadline = changes.StartsAt
|
||||
}
|
||||
changes.RegistrationDeadline = changes.StartsAt
|
||||
current.Name = changes.Name
|
||||
current.Description = changes.Description
|
||||
current.StartsAt = changes.StartsAt
|
||||
|
||||
@@ -518,32 +518,32 @@ func (s *Service) StartScrim(ctx context.Context, actor domain.Account, eventID
|
||||
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 {
|
||||
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, 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 {
|
||||
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, 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 {
|
||||
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, 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 {
|
||||
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, 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 {
|
||||
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 == "" {
|
||||
@@ -610,7 +610,7 @@ func (s *Service) RecordSeriesResult(ctx context.Context, actor domain.Account,
|
||||
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) {
|
||||
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
|
||||
@@ -622,6 +622,23 @@ func (s *Service) mutateSeries(ctx context.Context, actor domain.Account, series
|
||||
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)
|
||||
}
|
||||
@@ -636,6 +653,10 @@ func (s *Service) mutateSeries(ctx context.Context, actor domain.Account, 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user