Add event management features to API, including event cancellation, deletion, registration closure, and workflow balance generation. Introduce new roster management endpoints and enhance participant handling with avoided player preferences. Update database schema and service logic to support these changes.
This commit is contained in:
@@ -69,13 +69,26 @@ func New(service *application.Service, store application.Store, hub *realtime.Hu
|
||||
api.Post("/api/events", s.createEvent)
|
||||
api.Get("/api/events/{eventID}", s.getEvent)
|
||||
api.Put("/api/events/{eventID}", s.updateEvent)
|
||||
api.Delete("/api/events/{eventID}", s.deleteEvent)
|
||||
api.Post("/api/events/{eventID}/cancel", s.cancelEvent)
|
||||
api.Get("/api/events/{eventID}/tournament", s.getEventTournament)
|
||||
api.Get("/api/events/{eventID}/rsvps", s.rsvps)
|
||||
api.Post("/api/events/{eventID}/participants", s.createParticipant)
|
||||
api.Put("/api/events/{eventID}/rsvps/{playerID}", s.setRSVP)
|
||||
api.Delete("/api/events/{eventID}/rsvps/{playerID}", s.removeParticipant)
|
||||
api.Post("/api/events/{eventID}/balance", s.balance)
|
||||
api.Put("/api/events/{eventID}/teams", s.selectBalance)
|
||||
api.Get("/api/events/{eventID}/teams", s.teams)
|
||||
api.Post("/api/events/{eventID}/registration/close", s.closeRegistration)
|
||||
api.Post("/api/events/{eventID}/balance/generate", s.generateWorkflowBalance)
|
||||
api.Get("/api/events/{eventID}/roster", s.getRoster)
|
||||
api.Put("/api/events/{eventID}/roster", s.selectWorkflowBalance)
|
||||
api.Post("/api/events/{eventID}/roster/swap", s.swapRoster)
|
||||
api.Post("/api/events/{eventID}/roster/substitute", s.substituteRoster)
|
||||
api.Post("/api/events/{eventID}/roster/emergency-substitute", s.emergencySubstitute)
|
||||
api.Put("/api/events/{eventID}/roster/captain", s.setRosterCaptain)
|
||||
api.Post("/api/events/{eventID}/roster/confirm", s.confirmRosters)
|
||||
api.Post("/api/events/{eventID}/start", s.startScrim)
|
||||
api.Put("/api/teams/{teamID}/captain", s.assignCaptain)
|
||||
api.Post("/api/rulesets", s.saveRuleset)
|
||||
api.Get("/api/rulesets", s.listRulesets)
|
||||
@@ -88,6 +101,11 @@ func New(service *application.Service, store application.Store, hub *realtime.Hu
|
||||
api.Post("/api/tournaments", s.createTournament)
|
||||
api.Get("/api/tournaments/{id}", s.getTournament)
|
||||
api.Get("/api/series/{id}", s.getSeries)
|
||||
api.Post("/api/series/{id}/coin-toss", s.tossSeriesCoin)
|
||||
api.Post("/api/series/{id}/map-bans", s.banSeriesMap)
|
||||
api.Post("/api/series/{id}/map-pick", s.pickSeriesMap)
|
||||
api.Post("/api/series/{id}/hero-bans", s.banSeriesHero)
|
||||
api.Post("/api/series/{id}/map-result", s.recordSeriesResult)
|
||||
api.Post("/api/series/{id}/results", s.recordResult)
|
||||
api.Patch("/api/series/{id}/results/{index}", s.correctResult)
|
||||
api.Get("/api/events/stream", s.stream)
|
||||
@@ -220,12 +238,13 @@ func (s *Server) updateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
Ratings domain.Ratings `json:"ratings"`
|
||||
PreferredRoles []domain.Role `json:"preferredRoles"`
|
||||
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
|
||||
AvoidedPlayerIDs []string `json:"avoidedPlayerIds"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
id := who(r)
|
||||
out, err := s.service.UpdateOwnProfile(r.Context(), id.account, id.player, in.DisplayName, in.Ratings, in.PreferredRoles, in.PreferredPlayerIDs)
|
||||
out, err := s.service.UpdateOwnProfile(r.Context(), id.account, id.player, in.DisplayName, in.Ratings, in.PreferredRoles, in.PreferredPlayerIDs, in.AvoidedPlayerIDs)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -235,7 +254,7 @@ func (s *Server) players(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
from := time.Now().UTC()
|
||||
from := time.Unix(0, 0).UTC()
|
||||
if raw := r.URL.Query().Get("from"); raw != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
from = parsed
|
||||
@@ -303,6 +322,11 @@ func (s *Server) setRSVP(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, out, err, 200)
|
||||
}
|
||||
|
||||
func (s *Server) removeParticipant(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.service.RemoveParticipant(r.Context(), who(r).account, chi.URLParam(r, "eventID"), chi.URLParam(r, "playerID"))
|
||||
respond(w, nil, err, http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) balance(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.service.Balance(r.Context(), who(r).account, chi.URLParam(r, "eventID"))
|
||||
respond(w, out, err, 200)
|
||||
|
||||
243
backend/internal/adapter/httpapi/workflow_handlers.go
Normal file
243
backend/internal/adapter/httpapi/workflow_handlers.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"mixmaker/backend/internal/domain"
|
||||
)
|
||||
|
||||
func (s *Server) cancelEvent(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.CancelEvent(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) deleteEvent(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.service.DeleteEvent(r.Context(), who(r).account, chi.URLParam(r, "eventID"))
|
||||
respond(w, nil, err, http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) closeRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
RulesetID string `json:"rulesetId"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.CloseRegistration(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.RulesetID, in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) generateWorkflowBalance(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.GenerateBalance(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) getRoster(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := s.store.GetRoster(r.Context(), chi.URLParam(r, "eventID"))
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) selectWorkflowBalance(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Candidate domain.BalanceCandidate `json:"candidate"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.SelectWorkflowBalance(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.Candidate, in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) swapRoster(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
TeamAID string `json:"teamAId"`
|
||||
PlayerAID string `json:"playerAId"`
|
||||
TeamBID string `json:"teamBId"`
|
||||
PlayerBID string `json:"playerBId"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.SwapRoster(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.TeamAID, in.PlayerAID, in.TeamBID, in.PlayerBID, in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) substituteRoster(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleSubstitute(w, r, false)
|
||||
}
|
||||
|
||||
func (s *Server) emergencySubstitute(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleSubstitute(w, r, true)
|
||||
}
|
||||
|
||||
func (s *Server) handleSubstitute(w http.ResponseWriter, r *http.Request, emergency bool) {
|
||||
var in struct {
|
||||
TeamID string `json:"teamId"`
|
||||
OutgoingPlayerID string `json:"outgoingPlayerId"`
|
||||
ReservePlayerID string `json:"reservePlayerId"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.SubstituteRoster(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.TeamID, in.OutgoingPlayerID, in.ReservePlayerID, in.ExpectedVersion, emergency)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) setRosterCaptain(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
TeamID string `json:"teamId"`
|
||||
PlayerID string `json:"playerId"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.SetRosterCaptain(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.TeamID, in.PlayerID, in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) confirmRosters(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
ExpectedRosterVersion int `json:"expectedRosterVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.ConfirmRosters(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion, in.ExpectedRosterVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) startScrim(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
out, err := s.service.StartScrim(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusCreated)
|
||||
}
|
||||
|
||||
func (s *Server) tossSeriesCoin(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Seed string `json:"seed"`
|
||||
ActingTeamID string `json:"actingTeamId"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
series, err := s.store.GetSeries(r.Context(), chi.URLParam(r, "id"))
|
||||
if err == nil {
|
||||
err = s.authorizeTeam(r, series.EventID, in.ActingTeamID)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
out, err := s.service.TossSeriesCoin(r.Context(), who(r).account, series.ID, in.Seed, in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) banSeriesMap(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
TeamID string `json:"teamId"`
|
||||
Map string `json:"map"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
series, err := s.store.GetSeries(r.Context(), chi.URLParam(r, "id"))
|
||||
if err == nil {
|
||||
err = s.authorizeTeam(r, series.EventID, in.TeamID)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
out, err := s.service.BanSeriesMap(r.Context(), who(r).account, series.ID, in.TeamID, in.Map, in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) pickSeriesMap(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
TeamID string `json:"teamId"`
|
||||
Map string `json:"map"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
series, err := s.store.GetSeries(r.Context(), chi.URLParam(r, "id"))
|
||||
if err == nil {
|
||||
err = s.authorizeTeam(r, series.EventID, in.TeamID)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
out, err := s.service.PickSeriesMap(r.Context(), who(r).account, series.ID, in.TeamID, in.Map, in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) banSeriesHero(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
TeamID string `json:"teamId"`
|
||||
Hero string `json:"hero"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
series, err := s.store.GetSeries(r.Context(), chi.URLParam(r, "id"))
|
||||
if err == nil {
|
||||
err = s.authorizeTeam(r, series.EventID, in.TeamID)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
out, err := s.service.BanSeriesHero(r.Context(), who(r).account, series.ID, in.TeamID, in.Hero, in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) recordSeriesResult(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Outcome domain.MapOutcome `json:"outcome"`
|
||||
ActingTeamID string `json:"actingTeamId"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
if !decode(w, r, &in) {
|
||||
return
|
||||
}
|
||||
series, err := s.store.GetSeries(r.Context(), chi.URLParam(r, "id"))
|
||||
if err == nil {
|
||||
err = s.authorizeTeam(r, series.EventID, in.ActingTeamID)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
out, err := s.service.RecordSeriesResult(r.Context(), who(r).account, series.ID, in.Outcome, in.ExpectedVersion)
|
||||
respond(w, out, err, http.StatusOK)
|
||||
}
|
||||
@@ -63,12 +63,12 @@ func (s *Store) UpsertDiscordAccount(ctx context.Context, account domain.Account
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
p := domain.Player{ID: application.NewID(), AccountID: account.ID, DisplayName: account.Username, Ratings: domain.Ratings{Tank: 13, Damage: 13, Support: 13}, CreatedAt: account.CreatedAt, UpdatedAt: account.CreatedAt}
|
||||
var preferredRoles, preferredPlayers []byte
|
||||
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
||||
err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(account_id) DO UPDATE SET account_id=excluded.account_id
|
||||
RETURNING id,account_id,display_name,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,created_at,updated_at`,
|
||||
RETURNING id,account_id,display_name,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`,
|
||||
p.ID, p.AccountID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt).
|
||||
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err != nil {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
@@ -78,6 +78,9 @@ func (s *Store) UpsertDiscordAccount(ctx context.Context, account domain.Account
|
||||
if err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs); err != nil {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
if err = json.Unmarshal(avoidedPlayers, &p.AvoidedPlayerIDs); err != nil {
|
||||
return domain.Account{}, domain.Player{}, err
|
||||
}
|
||||
return account, p, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
@@ -94,36 +97,40 @@ func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
||||
func (s *Store) AccountBySession(ctx context.Context, token string) (domain.Account, domain.Player, error) {
|
||||
var a domain.Account
|
||||
var p domain.Player
|
||||
var preferredRoles, preferredPlayers []byte
|
||||
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT a.id,a.discord_id,a.username,a.avatar_url,a.role,a.created_at,
|
||||
p.id,p.account_id,p.display_name,p.tank_rating,p.damage_rating,p.support_rating,p.preferred_roles,p.preferred_player_ids,p.created_at,p.updated_at
|
||||
p.id,p.account_id,p.display_name,p.tank_rating,p.damage_rating,p.support_rating,p.preferred_roles,p.preferred_player_ids,p.avoided_player_ids,p.created_at,p.updated_at
|
||||
FROM sessions s JOIN accounts a ON a.id=s.account_id JOIN players p ON p.account_id=a.id
|
||||
WHERE s.token_hash=$1 AND s.expires_at>now()`, HashToken(token)).
|
||||
Scan(&a.ID, &a.DiscordID, &a.Username, &a.AvatarURL, &a.Role, &a.CreatedAt,
|
||||
&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs)
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(avoidedPlayers, &p.AvoidedPlayerIDs)
|
||||
}
|
||||
return a, p, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePlayer(ctx context.Context, p domain.Player) (domain.Player, error) {
|
||||
preferredRoles, _ := json.Marshal(p.PreferredRoles)
|
||||
preferredPlayers, _ := json.Marshal(p.PreferredPlayerIDs)
|
||||
avoidedPlayers, _ := json.Marshal(p.AvoidedPlayerIDs)
|
||||
err := s.pool.QueryRow(ctx, `UPDATE players SET display_name=$2,tank_rating=$3,damage_rating=$4,support_rating=$5,
|
||||
preferred_roles=$6,preferred_player_ids=$7,updated_at=$8
|
||||
preferred_roles=$6,preferred_player_ids=$7,avoided_player_ids=$8,updated_at=$9
|
||||
WHERE id=$1 RETURNING id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at`,
|
||||
p.ID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, preferredRoles, preferredPlayers, p.UpdatedAt).
|
||||
p.ID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, preferredRoles, preferredPlayers, avoidedPlayers, p.UpdatedAt).
|
||||
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &p.CreatedAt, &p.UpdatedAt)
|
||||
return p, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id,COALESCE(account_id,''),display_name,tank_rating,damage_rating,support_rating,
|
||||
preferred_roles,preferred_player_ids,created_at,updated_at FROM players ORDER BY id`)
|
||||
preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at FROM players ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -131,9 +138,9 @@ func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) {
|
||||
out := make([]domain.Player, 0)
|
||||
for rows.Next() {
|
||||
var p domain.Player
|
||||
var preferredRoles, preferredPlayers []byte
|
||||
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
||||
if err := rows.Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support,
|
||||
&preferredRoles, &preferredPlayers, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
&preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(preferredRoles, &p.PreferredRoles); err != nil {
|
||||
@@ -142,6 +149,9 @@ func (s *Store) ListPlayers(ctx context.Context) ([]domain.Player, error) {
|
||||
if err := json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(avoidedPlayers, &p.AvoidedPlayerIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -153,18 +163,21 @@ func (s *Store) CreateParticipant(ctx context.Context, p domain.Player, r domain
|
||||
return domain.Player{}, domain.RSVP{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var preferredRoles, preferredPlayers []byte
|
||||
var preferredRoles, preferredPlayers, avoidedPlayers []byte
|
||||
err = tx.QueryRow(ctx, `INSERT INTO players(id,account_id,display_name,tank_rating,damage_rating,support_rating,created_at,updated_at)
|
||||
VALUES($1,NULL,$2,$3,$4,$5,$6,$7)
|
||||
RETURNING id,COALESCE(account_id,''),display_name,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,created_at,updated_at`,
|
||||
RETURNING id,COALESCE(account_id,''),display_name,tank_rating,damage_rating,support_rating,preferred_roles,preferred_player_ids,avoided_player_ids,created_at,updated_at`,
|
||||
p.ID, p.DisplayName, p.Ratings.Tank, p.Ratings.Damage, p.Ratings.Support, p.CreatedAt, p.UpdatedAt).
|
||||
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
Scan(&p.ID, &p.AccountID, &p.DisplayName, &p.Ratings.Tank, &p.Ratings.Damage, &p.Ratings.Support, &preferredRoles, &preferredPlayers, &avoidedPlayers, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(preferredRoles, &p.PreferredRoles)
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(preferredPlayers, &p.PreferredPlayerIDs)
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(avoidedPlayers, &p.AvoidedPlayerIDs)
|
||||
}
|
||||
if err == nil {
|
||||
err = tx.QueryRow(ctx, `INSERT INTO rsvps(event_id,player_id,status,actor_account_id,source,updated_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6)
|
||||
@@ -182,19 +195,21 @@ func (s *Store) CreateParticipant(ctx context.Context, p domain.Player, r domain
|
||||
}
|
||||
|
||||
func (s *Store) CreateEvent(ctx context.Context, e domain.Event) (domain.Event, error) {
|
||||
err := s.pool.QueryRow(ctx, `INSERT INTO events(id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
RETURNING id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at`,
|
||||
e.ID, e.Name, e.Description, e.StartsAt, e.EndsAt, e.RegistrationDeadline, e.CreatedBy, e.CreatedAt, e.UpdatedAt).
|
||||
Scan(&e.ID, &e.Name, &e.Description, &e.StartsAt, &e.EndsAt, &e.RegistrationDeadline, &e.CreatedBy, &e.CreatedAt, &e.UpdatedAt)
|
||||
err := s.pool.QueryRow(ctx, `INSERT INTO events(id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at,state,version,ruleset_id)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
RETURNING id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at,state,version,ruleset_id,active_series_id,tournament_id`,
|
||||
e.ID, e.Name, e.Description, e.StartsAt, e.EndsAt, e.RegistrationDeadline, e.CreatedBy, e.CreatedAt, e.UpdatedAt, e.State, e.Version, e.RulesetID).
|
||||
Scan(&e.ID, &e.Name, &e.Description, &e.StartsAt, &e.EndsAt, &e.RegistrationDeadline, &e.CreatedBy, &e.CreatedAt, &e.UpdatedAt,
|
||||
&e.State, &e.Version, &e.RulesetID, &e.ActiveSeriesID, &e.TournamentID)
|
||||
return e, err
|
||||
}
|
||||
|
||||
func (s *Store) GetEvent(ctx context.Context, id string) (domain.Event, error) {
|
||||
var event domain.Event
|
||||
err := s.pool.QueryRow(ctx, `SELECT id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at
|
||||
err := s.pool.QueryRow(ctx, `SELECT id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at,state,version,ruleset_id,active_series_id,tournament_id
|
||||
FROM events WHERE id=$1`, id).
|
||||
Scan(&event.ID, &event.Name, &event.Description, &event.StartsAt, &event.EndsAt, &event.RegistrationDeadline, &event.CreatedBy, &event.CreatedAt, &event.UpdatedAt)
|
||||
Scan(&event.ID, &event.Name, &event.Description, &event.StartsAt, &event.EndsAt, &event.RegistrationDeadline, &event.CreatedBy, &event.CreatedAt, &event.UpdatedAt,
|
||||
&event.State, &event.Version, &event.RulesetID, &event.ActiveSeriesID, &event.TournamentID)
|
||||
return event, mapError(err)
|
||||
}
|
||||
|
||||
@@ -202,14 +217,15 @@ func (s *Store) UpdateEvent(ctx context.Context, event domain.Event) (domain.Eve
|
||||
err := s.pool.QueryRow(ctx, `UPDATE events
|
||||
SET name=$2,description=$3,starts_at=$4,ends_at=$5,registration_deadline=$6,updated_at=$7
|
||||
WHERE id=$1
|
||||
RETURNING id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at`,
|
||||
RETURNING id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at,state,version,ruleset_id,active_series_id,tournament_id`,
|
||||
event.ID, event.Name, event.Description, event.StartsAt, event.EndsAt, event.RegistrationDeadline, event.UpdatedAt).
|
||||
Scan(&event.ID, &event.Name, &event.Description, &event.StartsAt, &event.EndsAt, &event.RegistrationDeadline, &event.CreatedBy, &event.CreatedAt, &event.UpdatedAt)
|
||||
Scan(&event.ID, &event.Name, &event.Description, &event.StartsAt, &event.EndsAt, &event.RegistrationDeadline, &event.CreatedBy, &event.CreatedAt, &event.UpdatedAt,
|
||||
&event.State, &event.Version, &event.RulesetID, &event.ActiveSeriesID, &event.TournamentID)
|
||||
return event, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) ListEvents(ctx context.Context, from time.Time) ([]domain.Event, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at FROM events WHERE ends_at >= $1 ORDER BY starts_at`, from)
|
||||
rows, err := s.pool.Query(ctx, `SELECT id,name,description,starts_at,ends_at,registration_deadline,created_by,created_at,updated_at,state,version,ruleset_id,active_series_id,tournament_id FROM events WHERE ends_at >= $1 ORDER BY starts_at`, from)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -217,7 +233,8 @@ func (s *Store) ListEvents(ctx context.Context, from time.Time) ([]domain.Event,
|
||||
out := make([]domain.Event, 0)
|
||||
for rows.Next() {
|
||||
var e domain.Event
|
||||
if err := rows.Scan(&e.ID, &e.Name, &e.Description, &e.StartsAt, &e.EndsAt, &e.RegistrationDeadline, &e.CreatedBy, &e.CreatedAt, &e.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&e.ID, &e.Name, &e.Description, &e.StartsAt, &e.EndsAt, &e.RegistrationDeadline, &e.CreatedBy, &e.CreatedAt, &e.UpdatedAt,
|
||||
&e.State, &e.Version, &e.RulesetID, &e.ActiveSeriesID, &e.TournamentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
@@ -225,6 +242,126 @@ func (s *Store) ListEvents(ctx context.Context, from time.Time) ([]domain.Event,
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SaveEventWorkflow(ctx context.Context, event domain.Event, expectedVersion int) (domain.Event, error) {
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE events SET state=$2,version=$3,ruleset_id=$4,active_series_id=$5,tournament_id=$6,updated_at=$7
|
||||
WHERE id=$1 AND version=$8`, event.ID, event.State, event.Version, event.RulesetID, event.ActiveSeriesID, event.TournamentID, event.UpdatedAt, expectedVersion)
|
||||
if err == nil && tag.RowsAffected() == 0 {
|
||||
return domain.Event{}, domain.ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
return s.GetEvent(ctx, event.ID)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteEvent(ctx context.Context, eventID string) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
rows, err := tx.Query(ctx, `SELECT p.id FROM players p JOIN rsvps r ON r.player_id=p.id
|
||||
WHERE r.event_id=$1 AND p.account_id IS NULL`, eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
guestIDs := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err = rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
guestIDs = append(guestIDs, id)
|
||||
}
|
||||
rows.Close()
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `DELETE FROM audit_log WHERE subject_id=$1
|
||||
OR subject_id IN (SELECT id FROM teams WHERE event_id=$1)
|
||||
OR subject_id IN (SELECT id FROM series WHERE body->>'eventId'=$1)
|
||||
OR subject_id IN (SELECT id FROM tournaments WHERE event_id=$1)`, eventID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `DELETE FROM series WHERE body->>'eventId'=$1`, eventID); err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `DELETE FROM events WHERE id=$1`, eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
for _, guestID := range guestIDs {
|
||||
if _, err = tx.Exec(ctx, `DELETE FROM players WHERE id=$1 AND account_id IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM rsvps WHERE player_id=$1)`, guestID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) SaveRoster(ctx context.Context, roster domain.RosterDraft, expectedVersion int) (domain.RosterDraft, error) {
|
||||
body, _ := json.Marshal(roster)
|
||||
var err error
|
||||
if expectedVersion < 0 {
|
||||
_, err = s.pool.Exec(ctx, `INSERT INTO event_rosters(event_id,body,version) VALUES($1,$2,$3)
|
||||
ON CONFLICT(event_id) DO UPDATE SET body=excluded.body,version=excluded.version,updated_at=now()`,
|
||||
roster.EventID, body, roster.Version)
|
||||
} else {
|
||||
tag, execErr := s.pool.Exec(ctx, `UPDATE event_rosters SET body=$2,version=$3,updated_at=now() WHERE event_id=$1 AND version=$4`,
|
||||
roster.EventID, body, roster.Version, expectedVersion)
|
||||
err = execErr
|
||||
if execErr == nil && tag.RowsAffected() == 0 {
|
||||
err = domain.ErrConflict
|
||||
}
|
||||
}
|
||||
return roster, err
|
||||
}
|
||||
|
||||
func (s *Store) GetRoster(ctx context.Context, eventID string) (domain.RosterDraft, error) {
|
||||
var roster domain.RosterDraft
|
||||
var body []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT body FROM event_rosters WHERE event_id=$1`, eventID).Scan(&body)
|
||||
if err == nil {
|
||||
err = json.Unmarshal(body, &roster)
|
||||
}
|
||||
return roster, mapError(err)
|
||||
}
|
||||
|
||||
func (s *Store) StartScrim(ctx context.Context, event domain.Event, expectedVersion int, series []domain.Series, tournament *domain.Tournament) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
for _, item := range series {
|
||||
body, _ := json.Marshal(item)
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO series(id,tournament_id,body,version) VALUES($1,$2,$3,$4)`,
|
||||
item.ID, item.TournamentID, body, item.Version); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
}
|
||||
if tournament != nil {
|
||||
body, _ := json.Marshal(tournament)
|
||||
if _, err = tx.Exec(ctx, `INSERT INTO tournaments(id,event_id,body) VALUES($1,$2,$3)`,
|
||||
tournament.ID, tournament.EventID, body); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `UPDATE events SET state=$2,version=$3,active_series_id=$4,tournament_id=$5,updated_at=$6
|
||||
WHERE id=$1 AND version=$7`, event.ID, event.State, event.Version, event.ActiveSeriesID, event.TournamentID, event.UpdatedAt, expectedVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return domain.ErrConflict
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) UpsertRSVP(ctx context.Context, r domain.RSVP) (domain.RSVP, error) {
|
||||
err := s.pool.QueryRow(ctx, `INSERT INTO rsvps(event_id,player_id,status,actor_account_id,source,updated_at)
|
||||
VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(event_id,player_id) DO UPDATE
|
||||
@@ -252,6 +389,14 @@ func (s *Store) ListRSVPs(ctx context.Context, eventID string) ([]domain.RSVP, e
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteRSVP(ctx context.Context, eventID, playerID string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM rsvps WHERE event_id=$1 AND player_id=$2`, eventID, playerID)
|
||||
if err == nil && tag.RowsAffected() == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SaveTeams(ctx context.Context, eventID string, teams []domain.Team) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,10 +2,19 @@ package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mixmaker/backend/internal/application"
|
||||
"mixmaker/backend/internal/domain"
|
||||
)
|
||||
|
||||
type discardPublisher struct{}
|
||||
|
||||
func (discardPublisher) Publish(string, any) {}
|
||||
|
||||
func TestMigrationsAndReadiness(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
@@ -23,4 +32,177 @@ func TestMigrationsAndReadiness(t *testing.T) {
|
||||
if err := store.Ready(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var workflowColumns int
|
||||
if err := store.pool.QueryRow(ctx, `SELECT count(*) FROM information_schema.columns
|
||||
WHERE table_name='events' AND column_name IN ('state','version','ruleset_id','active_series_id','tournament_id')`).Scan(&workflowColumns); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if workflowColumns != 5 {
|
||||
t.Fatalf("workflow migration is incomplete: found %d event columns", workflowColumns)
|
||||
}
|
||||
var rosterTable bool
|
||||
if err := store.pool.QueryRow(ctx, `SELECT to_regclass('public.event_rosters') IS NOT NULL`).Scan(&rosterTable); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rosterTable {
|
||||
t.Fatal("event_rosters table is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullScrimPipeline(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 := "pipeline-admin-"+suffix, "pipeline-event-"+suffix
|
||||
playerIDs, seriesIDs := make([]string, 0, 10), make([]string, 0, 1)
|
||||
_, err = store.pool.Exec(ctx, `INSERT INTO accounts(id,discord_id,username,avatar_url,role,created_at)
|
||||
VALUES($1,$2,'Pipeline Admin','','admin',now())`, accountID, accountID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM audit_log WHERE actor_account_id=$1`, accountID)
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM events WHERE id=$1`, eventID)
|
||||
for _, seriesID := range seriesIDs {
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM series WHERE id=$1`, seriesID)
|
||||
}
|
||||
for _, playerID := range playerIDs {
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM players WHERE id=$1`, playerID)
|
||||
}
|
||||
_, _ = store.pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, accountID)
|
||||
}()
|
||||
|
||||
service := application.New(store, discardPublisher{})
|
||||
admin := domain.Account{ID: accountID, Role: domain.RoleAdmin}
|
||||
now := time.Now().UTC()
|
||||
event, err := service.CreateEvent(ctx, admin, domain.Event{
|
||||
ID: eventID, Name: "Pipeline", StartsAt: now.Add(24 * time.Hour), EndsAt: now.Add(28 * time.Hour),
|
||||
RegistrationDeadline: now.Add(23 * time.Hour), RulesetID: "standard-control-hybrid-control",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// CreateEvent owns the identifier, so use the returned value from here onward.
|
||||
eventID = event.ID
|
||||
for i := 0; i < 10; i++ {
|
||||
player, _, createErr := service.CreateParticipant(ctx, admin, eventID, fmt.Sprintf("Player %d", i+1),
|
||||
domain.Ratings{Tank: 18 + i%5, Damage: 19 + i%5, Support: 20 + i%5}, domain.Going)
|
||||
if createErr != nil {
|
||||
t.Fatal(createErr)
|
||||
}
|
||||
playerIDs = append(playerIDs, player.ID)
|
||||
}
|
||||
event, err = service.CloseRegistration(ctx, admin, eventID, event.RulesetID, event.Version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
balance, err := service.GenerateBalance(ctx, admin, eventID, event.Version)
|
||||
if err != nil || len(balance.Candidates) == 0 {
|
||||
t.Fatalf("balance failed: candidates=%d err=%v", len(balance.Candidates), err)
|
||||
}
|
||||
roster, err := service.SelectWorkflowBalance(ctx, admin, eventID, balance.Candidates[0], balance.Event.Version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, team := range roster.Teams {
|
||||
roster, err = service.SetRosterCaptain(ctx, admin, eventID, team.ID, team.Slots[0].PlayerID, roster.Version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
event, err = store.GetEvent(ctx, eventID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event, err = service.ConfirmRosters(ctx, admin, eventID, event.Version, roster.Version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
started, err := service.StartScrim(ctx, admin, eventID, event.Version)
|
||||
if err != nil || started.Series == nil {
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
series, err = service.RecordSeriesResult(ctx, admin, series.ID, domain.TeamAWin, series.Version)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if series.Phase != domain.SeriesComplete {
|
||||
t.Fatalf("series did not complete: %s", series.Phase)
|
||||
}
|
||||
event, err = store.GetEvent(ctx, eventID)
|
||||
if err != nil || event.State != domain.Completed {
|
||||
t.Fatalf("event did not complete: state=%s err=%v", event.State, err)
|
||||
}
|
||||
}
|
||||
|
||||
func firstUnbanned(pool, banned []string) string {
|
||||
for _, name := range pool {
|
||||
found := false
|
||||
for _, item := range banned {
|
||||
found = found || item == name
|
||||
}
|
||||
if !found {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstAvailableHero(draft *domain.HeroDraft, teamID string) string {
|
||||
for _, hero := range draft.Heroes {
|
||||
usedRole, usedHero, ownRepeat := false, false, false
|
||||
for _, role := range draft.CurrentRoles[teamID] {
|
||||
usedRole = usedRole || role == hero.Role
|
||||
}
|
||||
for _, ban := range draft.CurrentBans {
|
||||
usedHero = usedHero || ban.Value == hero.Name
|
||||
}
|
||||
for _, name := range draft.SeriesBans[teamID] {
|
||||
ownRepeat = ownRepeat || name == hero.Name
|
||||
}
|
||||
if !usedRole && !usedHero && !ownRepeat {
|
||||
return hero.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -24,8 +24,14 @@ type Store interface {
|
||||
GetEvent(context.Context, string) (domain.Event, error)
|
||||
UpdateEvent(context.Context, domain.Event) (domain.Event, error)
|
||||
ListEvents(context.Context, time.Time) ([]domain.Event, error)
|
||||
SaveEventWorkflow(context.Context, domain.Event, int) (domain.Event, error)
|
||||
SaveRoster(context.Context, domain.RosterDraft, int) (domain.RosterDraft, error)
|
||||
GetRoster(context.Context, string) (domain.RosterDraft, error)
|
||||
StartScrim(context.Context, domain.Event, int, []domain.Series, *domain.Tournament) error
|
||||
DeleteEvent(context.Context, string) error
|
||||
UpsertRSVP(context.Context, domain.RSVP) (domain.RSVP, error)
|
||||
ListRSVPs(context.Context, string) ([]domain.RSVP, error)
|
||||
DeleteRSVP(context.Context, string, string) error
|
||||
SaveTeams(context.Context, string, []domain.Team) error
|
||||
ListTeams(context.Context, string) ([]domain.Team, error)
|
||||
AssignCaptain(context.Context, string, string) (domain.Team, error)
|
||||
@@ -67,7 +73,7 @@ func (s *Service) Authenticate(ctx context.Context, session string) (domain.Acco
|
||||
return s.Store.AccountBySession(ctx, session)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, current domain.Player, displayName string, ratings domain.Ratings, preferredRoles []domain.Role, preferredPlayerIDs []string) (domain.Player, error) {
|
||||
func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, current domain.Player, displayName string, ratings domain.Ratings, preferredRoles []domain.Role, preferredPlayerIDs, avoidedPlayerIDs []string) (domain.Player, error) {
|
||||
if actor.ID != current.AccountID {
|
||||
return domain.Player{}, domain.ErrForbidden
|
||||
}
|
||||
@@ -80,6 +86,7 @@ func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, cu
|
||||
current.Ratings = ratings
|
||||
current.PreferredRoles = preferredRoles
|
||||
current.PreferredPlayerIDs = preferredPlayerIDs
|
||||
current.AvoidedPlayerIDs = avoidedPlayerIDs
|
||||
current.UpdatedAt = s.Now()
|
||||
if err := current.ValidatePreferences(); err != nil {
|
||||
return domain.Player{}, err
|
||||
@@ -97,7 +104,16 @@ func (s *Service) UpdateOwnProfile(ctx context.Context, actor domain.Account, cu
|
||||
return domain.Player{}, fmt.Errorf("%w: preferred teammate does not exist", domain.ErrInvalid)
|
||||
}
|
||||
}
|
||||
return s.Store.UpdatePlayer(ctx, current)
|
||||
for _, playerID := range current.AvoidedPlayerIDs {
|
||||
if !knownPlayers[playerID] {
|
||||
return domain.Player{}, fmt.Errorf("%w: avoided teammate does not exist", domain.ErrInvalid)
|
||||
}
|
||||
}
|
||||
out, err := s.Store.UpdatePlayer(ctx, current)
|
||||
if err == nil {
|
||||
s.Bus.Publish("players", out)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event domain.Event) (domain.Event, error) {
|
||||
@@ -108,6 +124,10 @@ func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event d
|
||||
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 == "" {
|
||||
event.RulesetID = "standard-control-hybrid-control"
|
||||
}
|
||||
if err := event.Validate(); err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
@@ -161,6 +181,9 @@ func (s *Service) SetRSVP(ctx context.Context, actor domain.Account, actorPlayer
|
||||
if err != nil {
|
||||
return domain.RSVP{}, err
|
||||
}
|
||||
if event.State != domain.RegistrationOpen {
|
||||
return domain.RSVP{}, fmt.Errorf("%w: registration is closed", domain.ErrConflict)
|
||||
}
|
||||
if !actor.IsAdmin() && s.Now().After(event.RegistrationDeadline) {
|
||||
return domain.RSVP{}, fmt.Errorf("%w: registration deadline has passed", domain.ErrConflict)
|
||||
}
|
||||
@@ -176,6 +199,25 @@ func (s *Service) SetRSVP(ctx context.Context, actor domain.Account, actorPlayer
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Service) RemoveParticipant(ctx context.Context, actor domain.Account, eventID, playerID string) error {
|
||||
if !actor.IsAdmin() {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
event, err := s.Store.GetEvent(ctx, eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if event.State != domain.RegistrationOpen {
|
||||
return fmt.Errorf("%w: registration is closed", domain.ErrConflict)
|
||||
}
|
||||
if err := s.Store.DeleteRSVP(ctx, eventID, playerID); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.Store.AppendAudit(ctx, actor.ID, "participant.removed", eventID, map[string]string{"playerId": playerID})
|
||||
s.Bus.Publish("event:"+eventID, map[string]string{"removedPlayerId": playerID})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateParticipant(ctx context.Context, actor domain.Account, eventID, displayName string, ratings domain.Ratings, status domain.RSVPStatus) (domain.Player, domain.RSVP, error) {
|
||||
if !actor.IsAdmin() {
|
||||
return domain.Player{}, domain.RSVP{}, domain.ErrForbidden
|
||||
@@ -187,9 +229,13 @@ func (s *Service) CreateParticipant(ctx context.Context, actor domain.Account, e
|
||||
if err := ratings.Validate(); err != nil {
|
||||
return domain.Player{}, domain.RSVP{}, err
|
||||
}
|
||||
if _, err := s.Store.GetEvent(ctx, eventID); err != nil {
|
||||
event, err := s.Store.GetEvent(ctx, eventID)
|
||||
if err != nil {
|
||||
return domain.Player{}, domain.RSVP{}, err
|
||||
}
|
||||
if event.State != domain.RegistrationOpen {
|
||||
return domain.Player{}, domain.RSVP{}, fmt.Errorf("%w: registration is closed", domain.ErrConflict)
|
||||
}
|
||||
now := s.Now()
|
||||
player := domain.Player{
|
||||
ID: NewID(),
|
||||
@@ -209,10 +255,11 @@ func (s *Service) CreateParticipant(ctx context.Context, actor domain.Account, e
|
||||
if err := rsvp.Validate(); err != nil {
|
||||
return domain.Player{}, domain.RSVP{}, err
|
||||
}
|
||||
player, rsvp, err := s.Store.CreateParticipant(ctx, player, rsvp)
|
||||
player, rsvp, err = s.Store.CreateParticipant(ctx, player, rsvp)
|
||||
if err == nil {
|
||||
_ = s.Store.AppendAudit(ctx, actor.ID, "participant.created", eventID, map[string]any{"player": player, "rsvp": rsvp})
|
||||
s.Bus.Publish("event:"+eventID, rsvp)
|
||||
s.Bus.Publish("players", player)
|
||||
}
|
||||
return player, rsvp, err
|
||||
}
|
||||
|
||||
497
backend/internal/application/workflow.go
Normal file
497
backend/internal/application/workflow.go
Normal file
@@ -0,0 +1,497 @@
|
||||
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.IsAdmin() {
|
||||
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.IsAdmin() {
|
||||
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.IsAdmin() {
|
||||
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.IsAdmin() {
|
||||
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.IsAdmin() {
|
||||
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.IsAdmin() {
|
||||
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.Swap(teamA, playerA, teamB, playerB); 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) SubstituteRoster(ctx context.Context, actor domain.Account, eventID, teamID, outgoingID, reserveID string, expectedVersion int, emergency bool) (domain.RosterDraft, error) {
|
||||
if !actor.IsAdmin() {
|
||||
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.IsAdmin() {
|
||||
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.IsAdmin() {
|
||||
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) StartScrim(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (ScrimStart, error) {
|
||||
if !actor.IsAdmin() {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -107,10 +107,11 @@ func buildCandidate(eventID string, players []Player, variant int, roleOrder []R
|
||||
}
|
||||
optimizeCandidate(c.Teams, players)
|
||||
c.Score = balanceScore(c.Teams, players)
|
||||
preferredRoles, preferredTeammates := preferenceStats(c.Teams, players)
|
||||
preferredRoles, preferredTeammates, avoidedTeammates := preferenceStats(c.Teams, players)
|
||||
c.Explanation = []string{
|
||||
fmt.Sprintf("%d preferred role assignments satisfied", preferredRoles),
|
||||
fmt.Sprintf("%d preferred teammate choices satisfied", preferredTeammates),
|
||||
fmt.Sprintf("%d avoided teammate choices satisfied", avoidedTeammates),
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -146,6 +147,7 @@ func balanceScore(teams []Team, players []Player) int {
|
||||
}
|
||||
}
|
||||
preferredTeammatePenalty := 0
|
||||
avoidedTeammatePenalty := 0
|
||||
for _, player := range players {
|
||||
teamIndex, active := teamByPlayer[player.ID]
|
||||
if !active {
|
||||
@@ -156,8 +158,13 @@ func balanceScore(teams []Team, players []Player) int {
|
||||
preferredTeammatePenalty += 8
|
||||
}
|
||||
}
|
||||
for _, avoidedID := range player.AvoidedPlayerIDs {
|
||||
if avoidedTeam, avoidedActive := teamByPlayer[avoidedID]; avoidedActive && avoidedTeam == teamIndex {
|
||||
avoidedTeammatePenalty += 14
|
||||
}
|
||||
}
|
||||
}
|
||||
score := (maxTotal-minTotal)*3 + weakRolePenalty + preferredRolePenalty + preferredTeammatePenalty
|
||||
score := (maxTotal-minTotal)*3 + weakRolePenalty + preferredRolePenalty + preferredTeammatePenalty + avoidedTeammatePenalty
|
||||
for _, totals := range roleTotals {
|
||||
minimum, maximum := math.MaxInt, 0
|
||||
for _, total := range totals {
|
||||
@@ -198,7 +205,7 @@ func optimizeCandidate(teams []Team, players []Player) {
|
||||
}
|
||||
}
|
||||
|
||||
func preferenceStats(teams []Team, players []Player) (int, int) {
|
||||
func preferenceStats(teams []Team, players []Player) (int, int, int) {
|
||||
playerByID := make(map[string]Player, len(players))
|
||||
teamByPlayer := make(map[string]int)
|
||||
roleByPlayer := make(map[string]Role)
|
||||
@@ -211,7 +218,7 @@ func preferenceStats(teams []Team, players []Player) (int, int) {
|
||||
roleByPlayer[slot.PlayerID] = slot.Role
|
||||
}
|
||||
}
|
||||
roleMatches, teammateMatches := 0, 0
|
||||
roleMatches, teammateMatches, avoidedMatches := 0, 0, 0
|
||||
for playerID, teamIndex := range teamByPlayer {
|
||||
player := playerByID[playerID]
|
||||
if len(player.PreferredRoles) > 0 && slices.Contains(player.PreferredRoles, roleByPlayer[playerID]) {
|
||||
@@ -222,8 +229,13 @@ func preferenceStats(teams []Team, players []Player) (int, int) {
|
||||
teammateMatches++
|
||||
}
|
||||
}
|
||||
for _, avoidedID := range player.AvoidedPlayerIDs {
|
||||
if avoidedTeam, ok := teamByPlayer[avoidedID]; ok && avoidedTeam != teamIndex {
|
||||
avoidedMatches++
|
||||
}
|
||||
}
|
||||
}
|
||||
return roleMatches, teammateMatches
|
||||
return roleMatches, teammateMatches, avoidedMatches
|
||||
}
|
||||
|
||||
func candidateSignature(candidate BalanceCandidate) string {
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestBalanceKeepsOddNumberOfFullTeams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalanceUsesSoftRoleAndTeammatePreferences(t *testing.T) {
|
||||
func TestBalanceUsesSoftRoleTeammateAndAvoidPreferences(t *testing.T) {
|
||||
players := make([]Player, 10)
|
||||
for i := range players {
|
||||
players[i] = Player{
|
||||
@@ -74,12 +74,13 @@ func TestBalanceUsesSoftRoleAndTeammatePreferences(t *testing.T) {
|
||||
}
|
||||
players[0].PreferredRoles = []Role{Support}
|
||||
players[0].PreferredPlayerIDs = []string{"p01"}
|
||||
players[0].AvoidedPlayerIDs = []string{"p02"}
|
||||
candidates, err := Balance("event", players)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
best := candidates[0]
|
||||
var teamForP0, teamForP1 int = -1, -1
|
||||
var teamForP0, teamForP1, teamForP2 int = -1, -1, -1
|
||||
var roleForP0 Role
|
||||
for teamIndex, team := range best.Teams {
|
||||
for _, slot := range team.Slots {
|
||||
@@ -88,10 +89,12 @@ func TestBalanceUsesSoftRoleAndTeammatePreferences(t *testing.T) {
|
||||
teamForP0, roleForP0 = teamIndex, slot.Role
|
||||
case "p01":
|
||||
teamForP1 = teamIndex
|
||||
case "p02":
|
||||
teamForP2 = teamIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
if teamForP0 != teamForP1 || roleForP0 != Support {
|
||||
t.Fatalf("preferences were not reflected in best candidate: role=%s teams=%d/%d", roleForP0, teamForP0, teamForP1)
|
||||
if teamForP0 != teamForP1 || teamForP0 == teamForP2 || roleForP0 != Support {
|
||||
t.Fatalf("preferences were not reflected in best candidate: role=%s teams=%d/%d avoid=%d", roleForP0, teamForP0, teamForP1, teamForP2)
|
||||
}
|
||||
}
|
||||
|
||||
136
backend/internal/domain/event_workflow.go
Normal file
136
backend/internal/domain/event_workflow.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type EventState string
|
||||
|
||||
const (
|
||||
RegistrationOpen EventState = "RegistrationOpen"
|
||||
RegistrationClosed EventState = "RegistrationClosed"
|
||||
Balancing EventState = "Balancing"
|
||||
RostersDraft EventState = "RostersDraft"
|
||||
RostersConfirmed EventState = "RostersConfirmed"
|
||||
Live EventState = "Live"
|
||||
Completed EventState = "Completed"
|
||||
Cancelled EventState = "Cancelled"
|
||||
)
|
||||
|
||||
func (e *Event) Transition(from []EventState, to EventState, expectedVersion int) error {
|
||||
if e.Version != expectedVersion {
|
||||
return fmt.Errorf("%w: stale event version", ErrConflict)
|
||||
}
|
||||
if !slices.Contains(from, e.State) {
|
||||
return fmt.Errorf("%w: event is %s", ErrConflict, e.State)
|
||||
}
|
||||
e.State = to
|
||||
e.Version++
|
||||
return nil
|
||||
}
|
||||
|
||||
type RosterDraft struct {
|
||||
EventID string `json:"eventId"`
|
||||
Teams []Team `json:"teams"`
|
||||
Reserve []string `json:"reserve"`
|
||||
Version int `json:"version"`
|
||||
Confirmed bool `json:"confirmed"`
|
||||
}
|
||||
|
||||
func (r RosterDraft) Validate(requireCaptains bool) error {
|
||||
if r.EventID == "" || len(r.Teams) < 2 {
|
||||
return fmt.Errorf("%w: at least two teams are required", ErrInvalid)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, team := range r.Teams {
|
||||
if len(team.Slots) != 5 {
|
||||
return fmt.Errorf("%w: every team must contain five players", ErrInvalid)
|
||||
}
|
||||
counts := map[Role]int{}
|
||||
for _, slot := range team.Slots {
|
||||
if slot.PlayerID == "" || seen[slot.PlayerID] {
|
||||
return fmt.Errorf("%w: roster players must be unique", ErrInvalid)
|
||||
}
|
||||
seen[slot.PlayerID] = true
|
||||
counts[slot.Role]++
|
||||
}
|
||||
if counts[Tank] != 1 || counts[Damage] != 2 || counts[Support] != 2 {
|
||||
return fmt.Errorf("%w: every team must use 1/2/2", ErrInvalid)
|
||||
}
|
||||
if requireCaptains && !slices.ContainsFunc(team.Slots, func(slot Slot) bool { return slot.PlayerID == team.CaptainPlayerID }) {
|
||||
return fmt.Errorf("%w: every team needs a captain", ErrInvalid)
|
||||
}
|
||||
}
|
||||
for _, playerID := range r.Reserve {
|
||||
if playerID == "" || seen[playerID] {
|
||||
return fmt.Errorf("%w: reserve players must be unique", ErrInvalid)
|
||||
}
|
||||
seen[playerID] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RosterDraft) Swap(teamAID, playerAID, teamBID, playerBID string) error {
|
||||
if r.Confirmed {
|
||||
return fmt.Errorf("%w: rosters are locked", ErrConflict)
|
||||
}
|
||||
var a, b *Slot
|
||||
for teamIndex := range r.Teams {
|
||||
for slotIndex := range r.Teams[teamIndex].Slots {
|
||||
slot := &r.Teams[teamIndex].Slots[slotIndex]
|
||||
if r.Teams[teamIndex].ID == teamAID && slot.PlayerID == playerAID {
|
||||
a = slot
|
||||
}
|
||||
if r.Teams[teamIndex].ID == teamBID && slot.PlayerID == playerBID {
|
||||
b = slot
|
||||
}
|
||||
}
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if a.Role != b.Role {
|
||||
return fmt.Errorf("%w: only equal roles can be swapped", ErrInvalid)
|
||||
}
|
||||
a.PlayerID, b.PlayerID = b.PlayerID, a.PlayerID
|
||||
a.Rating, b.Rating = b.Rating, a.Rating
|
||||
r.Version++
|
||||
return r.Validate(false)
|
||||
}
|
||||
|
||||
func (r *RosterDraft) Substitute(teamID, outgoingID, reserveID string, rating int) error {
|
||||
if r.Confirmed {
|
||||
return fmt.Errorf("%w: rosters are locked", ErrConflict)
|
||||
}
|
||||
reserveIndex := slices.Index(r.Reserve, reserveID)
|
||||
if reserveIndex < 0 {
|
||||
return fmt.Errorf("%w: player is not in reserve", ErrInvalid)
|
||||
}
|
||||
for teamIndex := range r.Teams {
|
||||
if r.Teams[teamIndex].ID != teamID {
|
||||
continue
|
||||
}
|
||||
for slotIndex := range r.Teams[teamIndex].Slots {
|
||||
slot := &r.Teams[teamIndex].Slots[slotIndex]
|
||||
if slot.PlayerID == outgoingID {
|
||||
slot.PlayerID, slot.Rating = reserveID, rating
|
||||
r.Reserve[reserveIndex] = outgoingID
|
||||
if r.Teams[teamIndex].CaptainPlayerID == outgoingID {
|
||||
r.Teams[teamIndex].CaptainPlayerID = ""
|
||||
}
|
||||
r.Version++
|
||||
return r.Validate(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
func (r *RosterDraft) EmergencySubstitute(teamID, outgoingID, reserveID string, rating int) error {
|
||||
confirmed := r.Confirmed
|
||||
r.Confirmed = false
|
||||
err := r.Substitute(teamID, outgoingID, reserveID, rating)
|
||||
r.Confirmed = confirmed
|
||||
return err
|
||||
}
|
||||
@@ -64,6 +64,7 @@ type Player struct {
|
||||
Ratings Ratings `json:"ratings"`
|
||||
PreferredRoles []Role `json:"preferredRoles"`
|
||||
PreferredPlayerIDs []string `json:"preferredPlayerIds"`
|
||||
AvoidedPlayerIDs []string `json:"avoidedPlayerIds"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
@@ -72,6 +73,9 @@ func (p Player) ValidatePreferences() error {
|
||||
if len(p.PreferredPlayerIDs) > 3 {
|
||||
return fmt.Errorf("%w: at most three preferred teammates are allowed", ErrInvalid)
|
||||
}
|
||||
if len(p.AvoidedPlayerIDs) > 3 {
|
||||
return fmt.Errorf("%w: at most three avoided teammates are allowed", ErrInvalid)
|
||||
}
|
||||
seenRoles := map[Role]bool{}
|
||||
for _, role := range p.PreferredRoles {
|
||||
if (role != Tank && role != Damage && role != Support) || seenRoles[role] {
|
||||
@@ -86,19 +90,31 @@ func (p Player) ValidatePreferences() error {
|
||||
}
|
||||
seenPlayers[playerID] = true
|
||||
}
|
||||
seenAvoids := map[string]bool{}
|
||||
for _, playerID := range p.AvoidedPlayerIDs {
|
||||
if playerID == "" || playerID == p.ID || seenAvoids[playerID] || seenPlayers[playerID] {
|
||||
return fmt.Errorf("%w: invalid avoided teammates", ErrInvalid)
|
||||
}
|
||||
seenAvoids[playerID] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
RegistrationDeadline time.Time `json:"registrationDeadline"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
RegistrationDeadline time.Time `json:"registrationDeadline"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
State EventState `json:"state"`
|
||||
Version int `json:"version"`
|
||||
RulesetID string `json:"rulesetId"`
|
||||
ActiveSeriesID string `json:"activeSeriesId"`
|
||||
TournamentID string `json:"tournamentId"`
|
||||
}
|
||||
|
||||
func (e Event) Validate() error {
|
||||
@@ -364,14 +380,27 @@ type MapResult struct {
|
||||
}
|
||||
|
||||
type Series struct {
|
||||
ID string `json:"id"`
|
||||
TournamentID string `json:"tournamentId"`
|
||||
TeamAID string `json:"teamAId"`
|
||||
TeamBID string `json:"teamBId"`
|
||||
WinnerTeamID string `json:"winnerTeamId"`
|
||||
BestOf int `json:"bestOf"`
|
||||
Results []MapResult `json:"results"`
|
||||
Version int `json:"version"`
|
||||
ID string `json:"id"`
|
||||
EventID string `json:"eventId"`
|
||||
TournamentID string `json:"tournamentId"`
|
||||
TeamAID string `json:"teamAId"`
|
||||
TeamBID string `json:"teamBId"`
|
||||
WinnerTeamID string `json:"winnerTeamId"`
|
||||
BestOf int `json:"bestOf"`
|
||||
RulesetID string `json:"rulesetId"`
|
||||
Phase SeriesPhase `json:"phase"`
|
||||
CoinToss *CoinToss `json:"coinToss,omitempty"`
|
||||
MapDraft *MapDraft `json:"mapDraft,omitempty"`
|
||||
HeroDraft *HeroDraft `json:"heroDraft,omitempty"`
|
||||
CurrentMap string `json:"currentMap"`
|
||||
PlayedMaps []string `json:"playedMaps"`
|
||||
NextMapPickerID string `json:"nextMapPickerId"`
|
||||
AvailableMaps []string `json:"availableMaps"`
|
||||
Maps []SeriesMap `json:"maps"`
|
||||
SeriesBans map[string][]string `json:"seriesBans"`
|
||||
Audit []SeriesAction `json:"audit"`
|
||||
Results []MapResult `json:"results"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
func (s *Series) RecordResult(result MapResult) error {
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestRatingsAndCaptainInvariants(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPlayerPreferenceLimits(t *testing.T) {
|
||||
player := Player{ID: "p1", PreferredRoles: []Role{Tank, Support}, PreferredPlayerIDs: []string{"p2", "p3", "p4"}}
|
||||
player := Player{ID: "p1", PreferredRoles: []Role{Tank, Support}, PreferredPlayerIDs: []string{"p2", "p3", "p4"}, AvoidedPlayerIDs: []string{"p5", "p6", "p7"}}
|
||||
if err := player.ValidatePreferences(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -27,6 +27,15 @@ func TestPlayerPreferenceLimits(t *testing.T) {
|
||||
if err := player.ValidatePreferences(); err == nil {
|
||||
t.Fatal("expected teammate preference limit")
|
||||
}
|
||||
player.PreferredPlayerIDs = []string{"p2"}
|
||||
player.AvoidedPlayerIDs = append(player.AvoidedPlayerIDs, "p8")
|
||||
if err := player.ValidatePreferences(); err == nil {
|
||||
t.Fatal("expected avoided teammate limit")
|
||||
}
|
||||
player.AvoidedPlayerIDs = []string{"p2"}
|
||||
if err := player.ValidatePreferences(); err == nil {
|
||||
t.Fatal("expected preferred/avoided overlap rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesBestOfThree(t *testing.T) {
|
||||
|
||||
186
backend/internal/domain/series_fsm.go
Normal file
186
backend/internal/domain/series_fsm.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SeriesPhase string
|
||||
|
||||
const (
|
||||
CoinTossPending SeriesPhase = "CoinTossPending"
|
||||
MapBanPhase SeriesPhase = "MapBan"
|
||||
MapPickPhase SeriesPhase = "MapPick"
|
||||
HeroBanPhase SeriesPhase = "HeroBan"
|
||||
PlayingPhase SeriesPhase = "Playing"
|
||||
SeriesComplete SeriesPhase = "Completed"
|
||||
)
|
||||
|
||||
type SeriesAction struct {
|
||||
Kind string `json:"kind"`
|
||||
Summary string `json:"summary"`
|
||||
TeamID string `json:"teamId,omitempty"`
|
||||
ActorAccountID string `json:"actorAccountId"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
type SeriesMap struct {
|
||||
Number int `json:"number"`
|
||||
Name string `json:"name"`
|
||||
HeroBans []DraftAction `json:"heroBans"`
|
||||
Result *MapResult `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
func NewSeries(id, eventID, tournamentID string, teams [2]string, rules Ruleset) (*Series, error) {
|
||||
if id == "" || eventID == "" || teams[0] == "" || teams[1] == "" || teams[0] == teams[1] {
|
||||
return nil, fmt.Errorf("%w: invalid series", ErrInvalid)
|
||||
}
|
||||
if err := rules.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Series{
|
||||
ID: id, EventID: eventID, TournamentID: tournamentID,
|
||||
TeamAID: teams[0], TeamBID: teams[1], BestOf: rules.BestOf,
|
||||
RulesetID: rules.ID, Phase: CoinTossPending,
|
||||
SeriesBans: map[string][]string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Series) Toss(seed, actor string, now time.Time, rules Ruleset) error {
|
||||
if s.Phase != CoinTossPending {
|
||||
return fmt.Errorf("%w: coin toss is unavailable", ErrConflict)
|
||||
}
|
||||
toss, err := TossCoin(s.TeamAID, s.TeamBID, seed, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.CoinToss = &toss
|
||||
first := toss.WinnerTeamID
|
||||
if rules.InitialMapBanner == 1 {
|
||||
first = s.otherTeam(first)
|
||||
}
|
||||
draft, err := NewMapDraft(s.availablePool(rules, 0), first, [2]string{s.TeamAID, s.TeamBID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.MapDraft, s.Phase = draft, MapBanPhase
|
||||
s.Version++
|
||||
s.Audit = append(s.Audit, SeriesAction{Kind: "coin_toss", Summary: "Coin toss won by " + toss.WinnerTeamID, TeamID: toss.WinnerTeamID, ActorAccountID: actor, At: now})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Series) BanMap(teamID, name, actor string, now time.Time, rules Ruleset) error {
|
||||
if s.Phase != MapBanPhase || s.MapDraft == nil {
|
||||
return fmt.Errorf("%w: map ban is unavailable", ErrConflict)
|
||||
}
|
||||
if err := s.MapDraft.Ban(teamID, name, actor, now); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Version++
|
||||
s.Audit = append(s.Audit, SeriesAction{Kind: "map_ban", Summary: name + " banned", TeamID: teamID, ActorAccountID: actor, At: now})
|
||||
selected, complete := s.MapDraft.Selected()
|
||||
if !complete {
|
||||
return nil
|
||||
}
|
||||
return s.startHeroDraft(selected, actor, now, rules)
|
||||
}
|
||||
|
||||
func (s *Series) PickMap(teamID, name, actor string, now time.Time, rules Ruleset) error {
|
||||
if s.Phase != MapPickPhase {
|
||||
return fmt.Errorf("%w: map pick is unavailable", ErrConflict)
|
||||
}
|
||||
if teamID != s.NextMapPickerID {
|
||||
return fmt.Errorf("%w: only the previous map loser can pick", ErrConflict)
|
||||
}
|
||||
if !slices.Contains(s.AvailableMaps, name) {
|
||||
return fmt.Errorf("%w: map unavailable", ErrInvalid)
|
||||
}
|
||||
s.Version++
|
||||
s.Audit = append(s.Audit, SeriesAction{Kind: "map_selected", Summary: name + " picked", TeamID: teamID, ActorAccountID: actor, At: now})
|
||||
return s.startHeroDraft(name, actor, now, rules)
|
||||
}
|
||||
|
||||
func (s *Series) startHeroDraft(selected, actor string, now time.Time, rules Ruleset) error {
|
||||
s.CurrentMap = selected
|
||||
first := s.otherTeam(s.CoinToss.WinnerTeamID)
|
||||
if rules.InitialHeroBanner == 0 {
|
||||
first = s.CoinToss.WinnerTeamID
|
||||
}
|
||||
draft, err := NewHeroDraft(rules.Heroes, [2]string{s.TeamAID, s.TeamBID}, first, rules.HeroBansPerTeam, s.SeriesBans)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.HeroDraft, s.Phase = draft, HeroBanPhase
|
||||
s.NextMapPickerID, s.AvailableMaps = "", nil
|
||||
s.Maps = append(s.Maps, SeriesMap{Number: len(s.Maps) + 1, Name: selected})
|
||||
if len(s.Audit) == 0 || s.Audit[len(s.Audit)-1].Kind != "map_selected" {
|
||||
s.Audit = append(s.Audit, SeriesAction{Kind: "map_selected", Summary: selected + " selected", ActorAccountID: actor, At: now})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Series) BanHero(teamID, hero, actor string, now time.Time) error {
|
||||
if s.Phase != HeroBanPhase || s.HeroDraft == nil {
|
||||
return fmt.Errorf("%w: hero ban is unavailable", ErrConflict)
|
||||
}
|
||||
if err := s.HeroDraft.Ban(teamID, hero, actor, now); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Version++
|
||||
s.Maps[len(s.Maps)-1].HeroBans = slices.Clone(s.HeroDraft.CurrentBans)
|
||||
s.Audit = append(s.Audit, SeriesAction{Kind: "hero_ban", Summary: hero + " banned", TeamID: teamID, ActorAccountID: actor, At: now})
|
||||
if s.HeroDraft.Complete() {
|
||||
s.SeriesBans = s.HeroDraft.SeriesBans
|
||||
s.Phase = PlayingPhase
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Series) RecordCurrentMap(outcome MapOutcome, actor string, now time.Time, rules Ruleset) error {
|
||||
if s.Phase != PlayingPhase || s.CurrentMap == "" {
|
||||
return fmt.Errorf("%w: map result is unavailable", ErrConflict)
|
||||
}
|
||||
result := MapResult{MapName: s.CurrentMap, Outcome: outcome, ActorAccountID: actor, RecordedAt: now}
|
||||
if err := s.RecordResult(result); err != nil {
|
||||
return err
|
||||
}
|
||||
s.PlayedMaps = append(s.PlayedMaps, s.CurrentMap)
|
||||
s.Maps[len(s.Maps)-1].Result = &result
|
||||
s.Audit = append(s.Audit, SeriesAction{Kind: "result", Summary: s.CurrentMap + ": " + string(outcome), ActorAccountID: actor, At: now})
|
||||
if s.WinnerTeamID != "" {
|
||||
s.Phase = SeriesComplete
|
||||
return nil
|
||||
}
|
||||
poolIndex := len(s.Maps)
|
||||
if poolIndex >= len(rules.MapPools) {
|
||||
poolIndex = len(rules.MapPools) - 1
|
||||
}
|
||||
switch outcome {
|
||||
case TeamAWin:
|
||||
s.NextMapPickerID = s.TeamBID
|
||||
case TeamBWin:
|
||||
s.NextMapPickerID = s.TeamAID
|
||||
default:
|
||||
// A draw has no loser, so the initial coin-toss loser receives the pick.
|
||||
s.NextMapPickerID = s.otherTeam(s.CoinToss.WinnerTeamID)
|
||||
}
|
||||
s.AvailableMaps = s.availablePool(rules, poolIndex)
|
||||
if len(s.AvailableMaps) == 0 {
|
||||
return fmt.Errorf("%w: no unplayed maps remain in pool", ErrInvalid)
|
||||
}
|
||||
s.MapDraft, s.HeroDraft, s.CurrentMap, s.Phase = nil, nil, "", MapPickPhase
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Series) availablePool(rules Ruleset, index int) []string {
|
||||
pool := slices.Clone(rules.MapPools[index])
|
||||
return slices.DeleteFunc(pool, func(name string) bool { return slices.Contains(s.PlayedMaps, name) })
|
||||
}
|
||||
|
||||
func (s Series) otherTeam(teamID string) string {
|
||||
if teamID == s.TeamAID {
|
||||
return s.TeamBID
|
||||
}
|
||||
return s.TeamAID
|
||||
}
|
||||
127
backend/internal/domain/workflow_test.go
Normal file
127
backend/internal/domain/workflow_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEventWorkflowRejectsStaleVersion(t *testing.T) {
|
||||
event := Event{State: RegistrationOpen, Version: 2}
|
||||
if err := event.Transition([]EventState{RegistrationOpen}, RegistrationClosed, 1); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("expected stale version conflict, got %v", err)
|
||||
}
|
||||
if event.State != RegistrationOpen || event.Version != 2 {
|
||||
t.Fatal("stale transition mutated the event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRosterSwapAndReserveKeepRoleShape(t *testing.T) {
|
||||
roster := testRoster()
|
||||
if err := roster.Swap("a", "a-d1", "b", "b-s1"); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("expected cross-role swap rejection, got %v", err)
|
||||
}
|
||||
if err := roster.Swap("a", "a-d1", "b", "b-d1"); err != nil {
|
||||
t.Fatalf("same-role swap failed: %v", err)
|
||||
}
|
||||
if err := roster.Substitute("a", "b-d1", "reserve", 30); err != nil {
|
||||
t.Fatalf("reserve substitution failed: %v", err)
|
||||
}
|
||||
if roster.Reserve[0] != "b-d1" {
|
||||
t.Fatalf("displaced player was not returned to reserve: %#v", roster.Reserve)
|
||||
}
|
||||
if err := roster.Validate(false); err != nil {
|
||||
t.Fatalf("valid 1/2/2 roster rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesFSMRunsCoinBansAndResult(t *testing.T) {
|
||||
rules := testRules()
|
||||
series, err := NewSeries("series", "event", "", [2]string{"a", "b"}, rules)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(100, 0).UTC()
|
||||
if err = series.Toss("seed", "admin", now, rules); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for !func() bool { _, selected := series.MapDraft.Selected(); return selected }() {
|
||||
name := firstAvailableMap(series.MapDraft)
|
||||
if err = series.BanMap(series.MapDraft.NextTeam(), name, "admin", now, rules); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if series.Phase != HeroBanPhase {
|
||||
t.Fatalf("expected hero bans, got %s", series.Phase)
|
||||
}
|
||||
for _, hero := range []string{"Tank A", "Tank B", "Damage A", "Damage B"} {
|
||||
if err = series.BanHero(series.HeroDraft.NextTeam(), hero, "admin", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if series.Phase != PlayingPhase {
|
||||
t.Fatalf("expected playing phase, got %s", series.Phase)
|
||||
}
|
||||
if err = series.RecordCurrentMap(TeamAWin, "admin", now, rules); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if series.Phase != MapPickPhase || len(series.PlayedMaps) != 1 || series.NextMapPickerID != "b" {
|
||||
t.Fatalf("next map was not prepared: phase=%s maps=%v", series.Phase, series.PlayedMaps)
|
||||
}
|
||||
if contains(series.AvailableMaps, series.PlayedMaps[0]) {
|
||||
t.Fatal("played map remained available in a later pool")
|
||||
}
|
||||
nextMap := series.AvailableMaps[0]
|
||||
if err = series.PickMap("a", nextMap, "admin", now, rules); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("winner was allowed to pick the next map: %v", err)
|
||||
}
|
||||
if err = series.PickMap("b", nextMap, "admin", now, rules); err != nil || series.Phase != HeroBanPhase {
|
||||
t.Fatalf("loser could not pick the next map: phase=%s err=%v", series.Phase, err)
|
||||
}
|
||||
}
|
||||
|
||||
func testRoster() RosterDraft {
|
||||
teams := make([]Team, 2)
|
||||
for teamIndex, id := range []string{"a", "b"} {
|
||||
slots := []Slot{{PlayerID: id + "-t", Role: Tank, Rating: 20}}
|
||||
for i := 1; i <= 2; i++ {
|
||||
slots = append(slots,
|
||||
Slot{PlayerID: fmt.Sprintf("%s-d%d", id, i), Role: Damage, Rating: 20},
|
||||
Slot{PlayerID: fmt.Sprintf("%s-s%d", id, i), Role: Support, Rating: 20},
|
||||
)
|
||||
}
|
||||
teams[teamIndex] = Team{ID: id, EventID: "event", Name: id, Slots: slots}
|
||||
}
|
||||
return RosterDraft{EventID: "event", Teams: teams, Reserve: []string{"reserve"}}
|
||||
}
|
||||
|
||||
func testRules() Ruleset {
|
||||
return Ruleset{
|
||||
ID: "rules", BestOf: 3, HeroBansPerTeam: 2,
|
||||
MapPools: [][]string{{"Map 1", "Map 2", "Map 3"}, {"Map 1", "Map 4", "Map 5"}, {"Map 1", "Map 2", "Map 6"}},
|
||||
Heroes: []Hero{
|
||||
{Name: "Tank A", Role: Tank}, {Name: "Tank B", Role: Tank},
|
||||
{Name: "Damage A", Role: Damage}, {Name: "Damage B", Role: Damage},
|
||||
{Name: "Support A", Role: Support}, {Name: "Support B", Role: Support},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func firstAvailableMap(draft *MapDraft) string {
|
||||
for _, name := range draft.Pool {
|
||||
if !contains(draft.Banned, name) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func contains(values []string, value string) bool {
|
||||
for _, item := range values {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
27
backend/internal/realtime/hub_test.go
Normal file
27
backend/internal/realtime/hub_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package realtime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHubPublishesToTopicAndWildcardSubscribers(t *testing.T) {
|
||||
hub := New()
|
||||
topic, cancelTopic := hub.Subscribe("series:s1")
|
||||
defer cancelTopic()
|
||||
all, cancelAll := hub.Subscribe("*")
|
||||
defer cancelAll()
|
||||
|
||||
hub.Publish("series:s1", map[string]int{"version": 2})
|
||||
|
||||
for name, channel := range map[string]<-chan Event{"topic": topic, "wildcard": all} {
|
||||
select {
|
||||
case event := <-channel:
|
||||
if event.Topic != "series:s1" || string(event.Data) != `{"version":2}` {
|
||||
t.Fatalf("%s subscriber received %#v", name, event)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("%s subscriber did not receive update", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
30
backend/migrations/006_event_workflow.sql
Normal file
30
backend/migrations/006_event_workflow.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- +mixmaker Up
|
||||
ALTER TABLE events
|
||||
ADD COLUMN IF NOT EXISTS state text NOT NULL DEFAULT 'RegistrationOpen',
|
||||
ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS ruleset_id text NOT NULL DEFAULT 'standard-control-hybrid-control',
|
||||
ADD COLUMN IF NOT EXISTS active_series_id text NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS tournament_id text NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE events
|
||||
DROP CONSTRAINT IF EXISTS events_state_check,
|
||||
ADD CONSTRAINT events_state_check CHECK (state IN (
|
||||
'RegistrationOpen', 'RegistrationClosed', 'Balancing',
|
||||
'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'
|
||||
));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_rosters (
|
||||
event_id text PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE,
|
||||
body jsonb NOT NULL,
|
||||
version integer NOT NULL DEFAULT 0,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- +mixmaker Down
|
||||
DROP TABLE IF EXISTS event_rosters;
|
||||
ALTER TABLE events
|
||||
DROP COLUMN IF EXISTS tournament_id,
|
||||
DROP COLUMN IF EXISTS active_series_id,
|
||||
DROP COLUMN IF EXISTS ruleset_id,
|
||||
DROP COLUMN IF EXISTS version,
|
||||
DROP COLUMN IF EXISTS state;
|
||||
7
backend/migrations/007_player_avoids.sql
Normal file
7
backend/migrations/007_player_avoids.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- +mixmaker Up
|
||||
ALTER TABLE players
|
||||
ADD COLUMN IF NOT EXISTS avoided_player_ids jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
-- +mixmaker Down
|
||||
ALTER TABLE players
|
||||
DROP COLUMN IF EXISTS avoided_player_ids;
|
||||
14
backend/migrations/008_cancelled_events.sql
Normal file
14
backend/migrations/008_cancelled_events.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- +mixmaker Up
|
||||
ALTER TABLE events DROP CONSTRAINT IF EXISTS events_state_check;
|
||||
ALTER TABLE events ADD CONSTRAINT events_state_check CHECK (state IN (
|
||||
'RegistrationOpen', 'RegistrationClosed', 'Balancing',
|
||||
'RostersDraft', 'RostersConfirmed', 'Live', 'Completed', 'Cancelled'
|
||||
));
|
||||
|
||||
-- +mixmaker Down
|
||||
UPDATE events SET state = 'Completed' WHERE state = 'Cancelled';
|
||||
ALTER TABLE events DROP CONSTRAINT IF EXISTS events_state_check;
|
||||
ALTER TABLE events ADD CONSTRAINT events_state_check CHECK (state IN (
|
||||
'RegistrationOpen', 'RegistrationClosed', 'Balancing',
|
||||
'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'
|
||||
));
|
||||
Reference in New Issue
Block a user