diff --git a/backend/internal/adapter/httpapi/server.go b/backend/internal/adapter/httpapi/server.go
index 0d4664b..109f224 100644
--- a/backend/internal/adapter/httpapi/server.go
+++ b/backend/internal/adapter/httpapi/server.go
@@ -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)
diff --git a/backend/internal/adapter/httpapi/workflow_handlers.go b/backend/internal/adapter/httpapi/workflow_handlers.go
new file mode 100644
index 0000000..17664c0
--- /dev/null
+++ b/backend/internal/adapter/httpapi/workflow_handlers.go
@@ -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)
+}
diff --git a/backend/internal/adapter/postgres/store.go b/backend/internal/adapter/postgres/store.go
index eb932e2..1497cb3 100644
--- a/backend/internal/adapter/postgres/store.go
+++ b/backend/internal/adapter/postgres/store.go
@@ -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 {
diff --git a/backend/internal/adapter/postgres/store_integration_test.go b/backend/internal/adapter/postgres/store_integration_test.go
index dfe046e..d0917f1 100644
--- a/backend/internal/adapter/postgres/store_integration_test.go
+++ b/backend/internal/adapter/postgres/store_integration_test.go
@@ -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 ""
}
diff --git a/backend/internal/application/service.go b/backend/internal/application/service.go
index 7240282..e9bf892 100644
--- a/backend/internal/application/service.go
+++ b/backend/internal/application/service.go
@@ -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
}
diff --git a/backend/internal/application/workflow.go b/backend/internal/application/workflow.go
new file mode 100644
index 0000000..9c23ad5
--- /dev/null
+++ b/backend/internal/application/workflow.go
@@ -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
+ }
+}
diff --git a/backend/internal/domain/balancer.go b/backend/internal/domain/balancer.go
index cfb0a8f..a01be73 100644
--- a/backend/internal/domain/balancer.go
+++ b/backend/internal/domain/balancer.go
@@ -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 {
diff --git a/backend/internal/domain/balancer_test.go b/backend/internal/domain/balancer_test.go
index 82a7b9f..408a744 100644
--- a/backend/internal/domain/balancer_test.go
+++ b/backend/internal/domain/balancer_test.go
@@ -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)
}
}
diff --git a/backend/internal/domain/event_workflow.go b/backend/internal/domain/event_workflow.go
new file mode 100644
index 0000000..95d2987
--- /dev/null
+++ b/backend/internal/domain/event_workflow.go
@@ -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
+}
diff --git a/backend/internal/domain/model.go b/backend/internal/domain/model.go
index 60f9f8f..1c02375 100644
--- a/backend/internal/domain/model.go
+++ b/backend/internal/domain/model.go
@@ -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 {
diff --git a/backend/internal/domain/model_test.go b/backend/internal/domain/model_test.go
index b1960f0..07f4799 100644
--- a/backend/internal/domain/model_test.go
+++ b/backend/internal/domain/model_test.go
@@ -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) {
diff --git a/backend/internal/domain/series_fsm.go b/backend/internal/domain/series_fsm.go
new file mode 100644
index 0000000..794428b
--- /dev/null
+++ b/backend/internal/domain/series_fsm.go
@@ -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
+}
diff --git a/backend/internal/domain/workflow_test.go b/backend/internal/domain/workflow_test.go
new file mode 100644
index 0000000..b641ea2
--- /dev/null
+++ b/backend/internal/domain/workflow_test.go
@@ -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
+}
diff --git a/backend/internal/realtime/hub_test.go b/backend/internal/realtime/hub_test.go
new file mode 100644
index 0000000..51d20c6
--- /dev/null
+++ b/backend/internal/realtime/hub_test.go
@@ -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)
+ }
+ }
+}
diff --git a/backend/migrations/006_event_workflow.sql b/backend/migrations/006_event_workflow.sql
new file mode 100644
index 0000000..4addff8
--- /dev/null
+++ b/backend/migrations/006_event_workflow.sql
@@ -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;
diff --git a/backend/migrations/007_player_avoids.sql b/backend/migrations/007_player_avoids.sql
new file mode 100644
index 0000000..b969d06
--- /dev/null
+++ b/backend/migrations/007_player_avoids.sql
@@ -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;
diff --git a/backend/migrations/008_cancelled_events.sql b/backend/migrations/008_cancelled_events.sql
new file mode 100644
index 0000000..c4e12a1
--- /dev/null
+++ b/backend/migrations/008_cancelled_events.sql
@@ -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'
+));
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 931edfd..fdb5808 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -13,6 +13,8 @@ describe('Mixmaker frontend', () => {
render( )
expect(await screen.findByRole('heading', { name: 'Your events' })).toBeInTheDocument()
expect(screen.getByText('Saturday Night Scrim')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'Open Summer Clash #4' })).toHaveClass('status-live')
+ expect(screen.getByRole('link', { name: 'Open Cancelled Community Mix' })).toHaveClass('status-cancelled')
})
it('renders the Discord login screen', async () => {
@@ -20,4 +22,20 @@ describe('Mixmaker frontend', () => {
render( )
expect(await screen.findByRole('link', { name: /continue with discord/i })).toHaveAttribute('href', '/api/auth/discord')
})
+
+ it('locks RSVP and exposes live navigation after the scrim starts', async () => {
+ window.history.replaceState({}, '', '/events/event-3')
+ render( )
+ expect(await screen.findByRole('link', { name: /open live series/i })).toHaveAttribute('href', '/series/series-1')
+ expect(screen.getByRole('button', { name: 'Going' })).toBeDisabled()
+ expect(screen.getByText('Registration has been closed by the organizer.')).toBeInTheDocument()
+ })
+
+ it('explains map selection and captain rules in the FAQ', async () => {
+ window.history.replaceState({}, '', '/faq')
+ render( )
+ expect(await screen.findByRole('heading', { name: 'Frequently asked questions' })).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: 'Who picks later maps?' })).toBeInTheDocument()
+ expect(screen.getByText(/team that lost that map immediately chooses/i)).toBeInTheDocument()
+ })
})
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 6b8e0a0..d117a9c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
+import { useCallback, useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
import {
CalendarDays, Check, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut,
Menu, Radio, RefreshCw, Shield, Sparkles, Swords, Trophy, UserRound,
@@ -11,7 +11,7 @@ import {
} from '@tanstack/react-router'
import {
api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent,
- type PlayerRole, type Registration, type RsvpStatus, type Series, type Session, type Team,
+ type Player, type PlayerRole, type RawRosterDraft, type Registration, type RsvpStatus, type Series, type Session, type Team,
} from './api/client'
import {
demoBalanceCandidates, demoBracket, demoEvents, demoProfile,
@@ -31,6 +31,7 @@ const protectedRoute = createRoute({
})
const indexRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/', beforeLoad: () => { throw redirect({ to: '/events' }) } })
const profileRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/profile', component: ProfilePage })
+const faqRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/faq', component: FAQPage })
const eventsRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events', component: EventsPage })
const eventRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events/$eventId', component: EventPage })
const liveRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/series/$seriesId', component: LiveSeriesPage })
@@ -41,13 +42,14 @@ const adminRoute = createRoute({
beforeLoad: ({ context }) => { if (context.session?.account.role !== 'admin') throw redirect({ to: '/events' }) },
})
const routeTree = rootRoute.addChildren([loginRoute, protectedRoute.addChildren([
- indexRoute, profileRoute, eventsRoute, eventRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute,
+ indexRoute, profileRoute, faqRoute, eventsRoute, eventRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute,
])])
const router = createRouter({ routeTree, context: { session: null } })
declare module '@tanstack/react-router' { interface Register { router: typeof router } }
const mainNavItems = [
{ to: '/events', label: 'Events', icon: CalendarDays },
+ { to: '/faq', label: 'FAQ', icon: CircleHelp },
...(demoMode ? [
{ to: '/series/$seriesId' as const, params: { seriesId: 'series-1' }, label: 'Live', icon: Radio },
{ to: '/bracket/$eventId' as const, params: { eventId: 'bracket-1' }, label: 'Bracket', icon: Trophy },
@@ -60,7 +62,31 @@ function Brand() {
function AppShell() {
const [open, setOpen] = useState(false)
+ const [realtimeStatus, setRealtimeStatus] = useState<'connected' | 'reconnecting'>('reconnecting')
+ const client = useQueryClient()
const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity })
+ useEffect(() => subscribeToEvents('*', (message) => {
+ const [scope, id] = message.topic.split(':', 2)
+ if (scope === 'event' && id) {
+ void client.invalidateQueries({ queryKey: ['event', id] })
+ void client.invalidateQueries({ queryKey: ['registrations', id] })
+ void client.invalidateQueries({ queryKey: ['roster', id] })
+ void client.invalidateQueries({ queryKey: ['teams', id] })
+ void client.invalidateQueries({ queryKey: ['tournament', id] })
+ void client.invalidateQueries({ queryKey: ['events'] })
+ } else if (scope === 'series' && id) {
+ void client.invalidateQueries({ queryKey: ['series', id] })
+ } else if (scope === 'tournament') {
+ void client.invalidateQueries({ queryKey: ['tournament'] })
+ } else if (scope === 'team') {
+ void client.invalidateQueries({ queryKey: ['teams'] })
+ } else if (message.topic === 'events') {
+ void client.invalidateQueries({ queryKey: ['events'] })
+ } else if (message.topic === 'players') {
+ void client.invalidateQueries({ queryKey: ['players'] })
+ void client.invalidateQueries({ queryKey: ['profile'] })
+ }
+ }, setRealtimeStatus), [client])
const logout = useMutation({
mutationFn: api.logout,
onSuccess: () => {
@@ -76,7 +102,7 @@ function AppShell() {
{navItems.map(({ icon: Icon, ...item }) => {item.label})}
- {initials} logout.mutate()} aria-label="Sign out" title="Sign out"> setOpen(!open)} aria-label="Toggle navigation" aria-expanded={open}>{open ? : }
+ {initials} logout.mutate()} aria-label="Sign out" title="Sign out"> setOpen(!open)} aria-label="Toggle navigation" aria-expanded={open}>{open ? : }
{open && {[...navItems, { to: '/profile' as const, label: 'Profile', icon: UserRound }].map(({ icon: Icon, ...item }) => setOpen(false)}> {item.label})} }
@@ -95,7 +121,7 @@ function LoginPage() {
function PageHeader({ eyebrow, title, description, actions }: { eyebrow: string; title: string; description?: string; actions?: ReactNode }) {
return {eyebrow} {title} {description &&
{description}
}
{actions && {actions}
}
}
-function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'success' | 'warning' | 'live' | 'neutral' | 'blue' }) { return {children} }
+function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'success' | 'warning' | 'live' | 'danger' | 'neutral' | 'blue' }) { return {children} }
function LoadingState({ label = 'Loading match dataβ¦' }: { label?: string }) { return
{label} Syncing with the server.
}
function ErrorState({ retry }: { retry?: () => void }) { return Couldnβt load this view Your data is safe. Check your connection and try again.
{retry &&
Try again }
}
@@ -110,13 +136,17 @@ function EventsPage() {
setFilter('upcoming')}>Upcoming setFilter('all')}>All } />
{demoMode && Live now{demoEvents[2].title} Semifinal 1 Β· Map 2 draft in progress
EMBER 1: 0 AZURE
Watch live }
Coming up {visible.length} scheduled events
- {visible.length === 0 ? No events yet New community nights will appear here.
: {visible.filter((e) => e.status !== 'live').map((event) => )}
}
+ {visible.length === 0 ? No events yet New community nights will appear here.
: {visible.map((event) => )}
}
}
function EventCard({ event }: { event: MixEvent }) {
const date = new Date(event.startsAt)
- return {date.toLocaleDateString(undefined, { day: '2-digit' })} {date.toLocaleDateString(undefined, { month: 'short' }).toUpperCase()}
{event.myRsvp === 'going' ? : }{event.myRsvp === 'going' ? 'Going' : 'Maybe'} {date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
{event.title} {event.description}
{event.counts.going} going View event
+ const statusBadge = event.status === 'cancelled' ? Cancelled
+ : event.status === 'completed' ? Completed
+ : event.status === 'live' ? LIVE
+ : {event.myRsvp === 'going' ? : }{event.myRsvp === 'going' ? 'Going' : 'Maybe'}
+ return {date.toLocaleDateString(undefined, { day: '2-digit' })} {date.toLocaleDateString(undefined, { month: 'short' }).toUpperCase()}
{statusBadge}{date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
{event.title} {event.description}
{event.counts.going} goingView event
}
function EventPage() {
@@ -134,24 +164,34 @@ function EventPage() {
void client.invalidateQueries({ queryKey: ['registrations', eventId] })
},
})
- useEffect(() => subscribeToEvents(`event:${eventId}`, () => {
- void client.invalidateQueries({ queryKey: ['event', eventId] })
- void client.invalidateQueries({ queryKey: ['registrations', eventId] })
- }), [client, eventId])
const labels: Record = { going: 'Going', maybe: 'Maybe', not_going: 'Canβt go' }
if (eventQuery.isLoading || registrations.isLoading || session.isLoading) return
if (eventQuery.isError || registrations.isError || session.isError || !eventQuery.data || !session.data) return { void eventQuery.refetch(); void registrations.refetch(); void session.refetch() }} />
const event = eventQuery.data
const rsvp = registrations.data?.find((registration) => registration.player.id === session.data.player.id)?.status ?? event.myRsvp
const counts = registrations.data?.reduce((total, registration) => ({ ...total, [registration.status]: total[registration.status] + 1 }), { going: 0, maybe: 0, not_going: 0 }) ?? event.counts
+ const attendanceTotal = Object.values(counts).reduce((total, count) => total + count, 0)
const statusOrder: Record = { going: 0, maybe: 1, not_going: 2 }
const attendees = [...(registrations.data ?? [])].sort((a, b) => statusOrder[a.status] - statusOrder[b.status] || a.player.displayName.localeCompare(b.player.displayName))
- return Registration open} />
-
Starts {new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
Registration closes {new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}
Are you playing? Your latest response is sent to the organizer.
{(Object.keys(labels) as RsvpStatus[]).map((status) => rsvpMutation.mutate(status)}>{status === 'going' ? : status === 'maybe' ? : }{labels[status]} )}
{rsvpMutation.isSuccess && Response saved Β· Server confirmed just now
}{rsvpMutation.isError && {rsvpMutation.error.message}
}
-
+ return
{event.status !== 'cancelled' && event.activeSeriesId && Open live series}{event.status !== 'cancelled' && event.tournamentId && Open bracket}{event.workflowState.replace(/([a-z])([A-Z])/g, '$1 $2')} >} />
+
Starts {new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
Registration closes {new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}
Are you playing? {event.workflowState === 'RegistrationOpen' ? 'Your latest response is sent to the organizer.' : 'Registration has been closed by the organizer.'}
{(Object.keys(labels) as RsvpStatus[]).map((status) => rsvpMutation.mutate(status)}>{status === 'going' ? : status === 'maybe' ? : }{labels[status]} )}
{rsvpMutation.isSuccess && Response saved Β· Server confirmed just now
}{rsvpMutation.isError && {rsvpMutation.error.message}
}
+
Attendance {attendanceTotal} responses
{attendanceTotal > 0 && <>{counts.going > 0 && }{counts.maybe > 0 && }{counts.not_going > 0 && }>}
Going {counts.going} Maybe {counts.maybe} No {counts.not_going}
{session.data.account.role === 'admin' && Manage participants }
Registered players Everyone can see who responded and their current status.
{attendees.length === 0 ? No responses yet.
: {attendees.map((registration) =>
{registration.player.displayName.slice(0, 2).toUpperCase()} {registration.player.displayName} {labels[registration.status]}
)}
}
}
+function FAQPage() {
+ return
+
01 How are teams balanced? The server builds role-locked 1 Tank / 2 Damage / 2 Support teams. Rank balance is the main goal. Preferred roles, preferred teammates, and up to three avoided teammates are soft preferences and never override a fair valid roster.
+
02 How does registration work? Choose Going, Maybe, or Canβt go before registration closes. Only Going players enter balancing. After an admin closes registration, normal RSVP changes are locked.
+
03 What happens before the scrim? An admin generates balance candidates, chooses one, may swap players of the same role or use the reserve, assigns captains, confirms the rosters, and starts the scrim.
+
04 How is the first map selected? The initial coin toss decides which team bans first. Teams alternate map bans until one map remains. That map becomes the first map of the series.
+
05 Who picks later maps? After a captain or admin records a map result, the team that lost that map immediately chooses the next map from the next ruleset pool. Already played maps are unavailable. After a draw, the initial coin-toss loser receives the pick.
+
06 How do hero bans work? Before every map, teams make two bans each in alternating order. The initial coin-toss loser bans first. A teamβs two bans must use different roles, and a team cannot repeat its own hero ban during the same series. It may ban a hero previously banned by the opponent.
+
07 How does Bo3 scoring work? The first team to win two maps wins the series. A captain selects the result after each map; admins can correct mistakes. Tournament winners advance automatically.
+
08 What can captains do? Captains can perform actions for their team: map bans, later-map picks, and hero bans. Admins can perform or correct any authoritative action. Every command is versioned and recorded in the match history.
+
+}
+
function ProfilePage() {
const client = useQueryClient()
const language = useLanguage()
@@ -161,15 +201,24 @@ function ProfilePage() {
const [ratings, setRatings] = useState
>({ tank: 20, damage: 20, support: 20 })
const [preferredRoles, setPreferredRoles] = useState([])
const [preferredPlayerIds, setPreferredPlayerIds] = useState([])
+ const [avoidedPlayerIds, setAvoidedPlayerIds] = useState([])
useEffect(() => {
if (profile.data) {
setRatings({ tank: profile.data.ratings.tank, damage: profile.data.ratings.damage, support: profile.data.ratings.support })
setPreferredRoles(profile.data.preferredRoles)
setPreferredPlayerIds(profile.data.preferredPlayerIds)
+ setAvoidedPlayerIds(profile.data.avoidedPlayerIds)
}
}, [profile.data])
+ useEffect(() => {
+ if (players.data) {
+ const eligible = new Set(players.data.filter((player) => Boolean(player.accountId)).map((player) => player.id))
+ setPreferredPlayerIds((current) => current.filter((id) => eligible.has(id)))
+ setAvoidedPlayerIds((current) => current.filter((id) => eligible.has(id)))
+ }
+ }, [players.data])
const update = useMutation({
- mutationFn: () => api.updateRatings(profile.data!.displayName, ratings, preferredRoles, preferredPlayerIds),
+ mutationFn: () => api.updateRatings(profile.data!.displayName, ratings, preferredRoles, preferredPlayerIds, avoidedPlayerIds),
onSuccess: (player) => {
client.setQueryData(['profile'], player)
void client.invalidateQueries({ queryKey: ['session'] })
@@ -180,7 +229,8 @@ function ProfilePage() {
if (!profile.data || !players.data) return
const roles: Array<{ key: PlayerRole; label: string; hint: string }> = [{ key: 'tank', label: 'Tank', hint: 'Space, pressure, and frontline' }, { key: 'damage', label: 'Damage', hint: 'Eliminations and map control' }, { key: 'support', label: 'Support', hint: 'Sustain, utility, and tempo' }]
const submit = (e: FormEvent) => { e.preventDefault(); update.mutate() }
- return {profile.data.displayName.slice(0, 2).toUpperCase()}
{profile.data.displayName} {profile.data.battleTag}
Connected via Discord
+ const eligiblePlayers = players.data.filter((player) => player.id !== profile.data.id && Boolean(player.accountId))
+ return {profile.data.displayName.slice(0, 2).toUpperCase()}
{profile.data.displayName} {profile.data.battleTag}
Connected via Discord
}
type AdminTab = 'events' | 'participants' | 'balance'
@@ -193,7 +243,7 @@ function AdminPage() {
useEffect(() => { if (!selectedId && events.data?.[0]) setSelectedId(events.data[0].id) }, [events.data, selectedId])
if (events.isLoading) return
if (events.isError) return void events.refetch()} />
- return {events.data && events.data.length > 0 && { setSelectedId(e.target.value); setCreateMode(false) }}>{events.data.map((event) => {event.title} )} } { setCreateMode(true); setTab('events') }}> New event >} />{(['events', 'participants', 'balance'] as AdminTab[]).map((item) => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : 'Balance teams'} )}
{tab === 'events' && { setCreateMode(false); setSelectedId(event.id) }} />}{tab === 'participants' && selected && }{tab === 'balance' && selected && }
+ return {events.data && events.data.length > 0 && { setSelectedId(e.target.value); setCreateMode(false) }}>{events.data.map((event) => {event.title} )} } { setCreateMode(true); setTab('events') }}> New event >} />{(['events', 'participants', 'balance'] as AdminTab[]).map((item) => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : 'Balance teams'} )}
{tab === 'events' && { setCreateMode(false); setSelectedId(event.id) }} onDeleted={() => { setSelectedId(undefined); setCreateMode(false) }} />}{tab === 'participants' && selected && }{tab === 'balance' && selected && }
}
function toLocalInput(value: string) {
@@ -214,8 +264,9 @@ function eventInput(event?: MixEvent): EventInput {
}
}
-function EventSetup({ event, onCreated }: { event?: MixEvent; onCreated: (event: MixEvent) => void }) {
+function EventSetup({ event, onCreated, onDeleted }: { event?: MixEvent; onCreated: (event: MixEvent) => void; onDeleted: () => void }) {
const client = useQueryClient()
+ const language = useLanguage()
const initialForm = useMemo(() => eventInput(event), [event])
const [form, setForm] = useState(initialForm)
useEffect(() => setForm(initialForm), [initialForm])
@@ -229,8 +280,40 @@ function EventSetup({ event, onCreated }: { event?: MixEvent; onCreated: (event:
onCreated(saved)
},
})
+ const cancel = useMutation({
+ mutationFn: () => {
+ if (!event) return Promise.reject(new Error('Event is required'))
+ if (demoMode) return Promise.resolve({ ...event, status: 'cancelled' as const, workflowState: 'Cancelled' as const, version: event.version + 1 })
+ return api.cancelEvent(event.id, event.version)
+ },
+ onSuccess: (cancelled) => {
+ client.setQueryData(['events'], (current) => current?.map((item) => item.id === cancelled.id ? cancelled : item))
+ void client.invalidateQueries({ queryKey: ['events'] })
+ void client.invalidateQueries({ queryKey: ['event', cancelled.id] })
+ onCreated(cancelled)
+ },
+ })
+ const remove = useMutation({
+ mutationFn: async () => {
+ if (!event) return
+ if (!demoMode) await api.deleteEvent(event.id)
+ },
+ onSuccess: () => {
+ client.setQueryData(['events'], (current) => current?.filter((item) => item.id !== event?.id))
+ void client.invalidateQueries({ queryKey: ['events'] })
+ onDeleted()
+ },
+ })
const set = (key: keyof EventInput, value: string) => setForm((current) => ({ ...current, [key]: value }))
- return
+ return
}
function Participants({ eventId }: { eventId: string }) {
@@ -254,6 +337,7 @@ function Participants({ eventId }: { eventId: string }) {
ratings: { ...ratings, updatedAt: now },
preferredRoles: [],
preferredPlayerIds: [],
+ avoidedPlayerIds: [],
},
status,
updatedAt: now,
@@ -275,13 +359,122 @@ function Participants({ eventId }: { eventId: string }) {
void client.invalidateQueries({ queryKey: ['events'] })
},
})
+ const remove = useMutation({
+ mutationFn: (playerId: string) => demoMode ? Promise.resolve() : api.removeParticipant(eventId, playerId),
+ onSuccess: (_, playerId) => {
+ client.setQueryData(['registrations', eventId], (current) => current?.filter((registration) => registration.player.id !== playerId) ?? [])
+ if (!demoMode) void client.invalidateQueries({ queryKey: ['events'] })
+ },
+ })
if (rows.isLoading) return
if (rows.isError || !rows.data) return void rows.refetch()} />
const names = new Map(rows.data.map((registration) => [registration.player.id, registration.player.displayName]))
- return { event.preventDefault(); create.mutate() }}>Add participant Create a guest player when they cannot sign in with Discord.
Nickname setDisplayName(event.target.value)} /> {(['tank', 'damage', 'support'] as PlayerRole[]).map((role) => {role === 'tank' ? 'Tank rank' : role === 'damage' ? 'Damage rank' : 'Support rank'} setRatings((current) => ({ ...current, [role]: toRankOrdinal(Number(event.target.value)) }))}>{options.map((option) => {option.label} )} )}Status setStatus(event.target.value as RsvpStatus)}>Going Maybe Not going {create.isPending ? 'Addingβ¦' : 'Add participant'}
{create.isError && {create.error.message}
} Participant responses Admin overrides are attributed and audited.
{rows.data.filter((r) => r.status === 'going').length} going {rows.data.filter((r) => r.status === 'maybe').length} maybe
{rows.data.map((r) =>
{r.player.displayName.slice(0, 2).toUpperCase()}
{r.player.displayName} {r.player.preferredRoles.map((role) => role[0].toUpperCase() + role.slice(1)).join(' Β· ') || 'No preferred roles'}{r.player.preferredPlayerIds.length > 0 && <> Β· Preferred with {r.player.preferredPlayerIds.map((id) => names.get(id) ?? id.slice(0, 6)).join(', ')}>}{r.changedBy?.source === 'admin' && <> Β· set by {r.changedBy.displayName} >}
T Β· {rankLabel(r.player.ratings.tank, language)} D Β· {rankLabel(r.player.ratings.damage, language)} S Β· {rankLabel(r.player.ratings.support, language)}
override.mutate({ playerId: r.player.id, status: e.target.value as RsvpStatus })}>Going Maybe Not going )}
{override.isError && {override.error.message}
}
+ return { event.preventDefault(); create.mutate() }}>Add participant Create a guest player when they cannot sign in with Discord.
Nickname setDisplayName(event.target.value)} /> {(['tank', 'damage', 'support'] as PlayerRole[]).map((role) => {role === 'tank' ? 'Tank rank' : role === 'damage' ? 'Damage rank' : 'Support rank'} setRatings((current) => ({ ...current, [role]: toRankOrdinal(Number(event.target.value)) }))}>{options.map((option) => {option.label} )} )}Status setStatus(event.target.value as RsvpStatus)}>Going Maybe Not going {create.isPending ? 'Addingβ¦' : 'Add participant'}
{create.isError && {create.error.message}
} Participant responses Admin overrides are attributed and audited.
{rows.data.filter((r) => r.status === 'going').length} going {rows.data.filter((r) => r.status === 'maybe').length} maybe
{rows.data.map((r) =>
{r.player.displayName.slice(0, 2).toUpperCase()}
{r.player.displayName} {r.player.preferredRoles.map((role) => role[0].toUpperCase() + role.slice(1)).join(' Β· ') || 'No preferred roles'}{r.player.preferredPlayerIds.length > 0 && <> Β· Preferred with {r.player.preferredPlayerIds.map((id) => names.get(id) ?? id.slice(0, 6)).join(', ')}>}{r.changedBy?.source === 'admin' && <> Β· set by {r.changedBy.displayName} >}
T Β· {rankLabel(r.player.ratings.tank, language)} D Β· {rankLabel(r.player.ratings.damage, language)} S Β· {rankLabel(r.player.ratings.support, language)}
override.mutate({ playerId: r.player.id, status: e.target.value as RsvpStatus })}>Going Maybe Not going remove.mutate(r.player.id)}> )}
{(override.isError || remove.isError) && {override.error?.message ?? remove.error?.message}
}
}
function BalanceView({ eventId }: { eventId: string }) {
+ if (demoMode) return
+ return
+}
+
+function WorkflowControl({ eventId }: { eventId: string }) {
+ const client = useQueryClient()
+ const event = useQuery({ queryKey: ['event', eventId], queryFn: () => api.event(eventId) })
+ const players = useQuery({ queryKey: ['players'], queryFn: api.players })
+ const roster = useQuery({
+ queryKey: ['roster', eventId],
+ queryFn: () => api.roster(eventId),
+ enabled: Boolean(event.data && ['RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].includes(event.data.workflowState)),
+ retry: false,
+ })
+ const [candidates, setCandidates] = useState>['candidates']>([])
+ const [selectedCandidate, setSelectedCandidate] = useState(0)
+ const refresh = useCallback(() => {
+ void client.invalidateQueries({ queryKey: ['event', eventId] })
+ void client.invalidateQueries({ queryKey: ['events'] })
+ void client.invalidateQueries({ queryKey: ['roster', eventId] })
+ void client.invalidateQueries({ queryKey: ['teams', eventId] })
+ }, [client, eventId])
+ const close = useMutation({
+ mutationFn: () => api.closeRegistration(eventId, event.data!.rulesetId, event.data!.version),
+ onSuccess: refresh,
+ onError: refresh,
+ })
+ const generate = useMutation({
+ mutationFn: () => api.generateWorkflowBalance(eventId, event.data!.version),
+ onSuccess: (result) => {
+ setCandidates(result.candidates)
+ setSelectedCandidate(0)
+ refresh()
+ },
+ onError: refresh,
+ })
+ const select = useMutation({
+ mutationFn: () => api.selectWorkflowBalance(eventId, candidates[selectedCandidate], event.data!.version),
+ onSuccess: refresh,
+ onError: refresh,
+ })
+ const confirm = useMutation({
+ mutationFn: () => api.confirmRosters(eventId, event.data!.version, roster.data!.version),
+ onSuccess: refresh,
+ onError: refresh,
+ })
+ const start = useMutation({
+ mutationFn: () => api.startScrim(eventId, event.data!.version),
+ onSuccess: (result) => {
+ refresh()
+ if (result.series) void router.navigate({ to: '/series/$seriesId', params: { seriesId: result.series.id } })
+ else if (result.tournament) void router.navigate({ to: '/bracket/$eventId', params: { eventId } })
+ },
+ onError: refresh,
+ })
+ if (event.isLoading || players.isLoading) return
+ if (event.isError || players.isError || !event.data || !players.data) return { void event.refetch(); void players.refetch() }} />
+ const state = event.data.workflowState
+ const error = close.error ?? generate.error ?? select.error ?? confirm.error ?? start.error
+ return
+
+ {(['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'] as const).map((step, index) => {
+ const activeIndex = ['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].indexOf(state)
+ return {index + 1} {step.replace(/([a-z])([A-Z])/g, '$1 $2')}
+ })}
+
+ {state === 'RegistrationOpen' &&
close.mutate()} />}
+ {state === 'RegistrationClosed' && generate.mutate()} />}
+ {state === 'Balancing' && candidates.length === 0 && generate.mutate()} />}
+ {state === 'Balancing' && candidates.length > 0 && {candidates.map((candidate, index) => setSelectedCandidate(index)}>0{index + 1} Score {candidate.score} {candidate.explanations[0]} )} {candidates[selectedCandidate].teams.map((team) => )}
Selecting creates a versioned roster draft.
select.mutate()}>Use this balance }
+ {roster.data && }
+ {state === 'RostersDraft' && roster.data && confirm.mutate()} />}
+ {state === 'RostersConfirmed' && start.mutate()} />}
+ {state === 'Live' && Scrim is live Coin toss, bans, and map results are now available.
{event.data.activeSeriesId ? Open series : Open bracket} }
+ {state === 'Completed' && Scrim completed The final result and full audit trail are preserved.
}
+ {error && {error.message}
}
+
+}
+
+function WorkflowAction({ title, description, button, pending, onClick }: { title: string; description: string; button: string; pending: boolean; onClick: () => void }) {
+ return {pending ? 'Workingβ¦' : button}
+}
+
+function WorkflowRosterEditor({ eventId, roster, players, locked, onChanged }: { eventId: string; roster: RawRosterDraft; players: Player[]; locked: boolean; onChanged: () => void }) {
+ const language = useLanguage()
+ const names = new Map(players.map((player) => [player.id, player.displayName]))
+ const mutate = useMutation({
+ mutationFn: (action: { kind: 'swap'; teamAId: string; playerAId: string; teamBId: string; playerBId: string } | { kind: 'substitute'; teamId: string; outgoingPlayerId: string; reservePlayerId: string } | { kind: 'captain'; teamId: string; playerId: string }) => {
+ if (action.kind === 'swap') return api.swapRoster(eventId, { ...action, expectedVersion: roster.version })
+ if (action.kind === 'substitute') return api.substituteRoster(eventId, { ...action, expectedVersion: roster.version })
+ return api.setRosterCaptain(eventId, action.teamId, action.playerId, roster.version)
+ },
+ onSuccess: onChanged,
+ onError: onChanged,
+ })
+ return Roster editor Version {roster.version} Β· swaps are restricted to the same role.
{roster.confirmed &&
Locked}
{roster.teams.map((team) =>
{team.name} Captain mutate.mutate({ kind: 'captain', teamId: team.id, playerId: event.target.value })}>Choose captain {team.slots.map((slot) => {names.get(slot.playerId) ?? slot.playerId} )} {team.slots.map((slot) => {
+ const swapOptions = roster.teams.flatMap((other) => other.id === team.id ? [] : other.slots.filter((candidate) => candidate.role === slot.role).map((candidate) => ({ ...candidate, teamId: other.id })))
+ return {slot.role[0]} {names.get(slot.playerId) ?? slot.playerId} {rankLabel(slot.rating, language)} {!locked && <> { const [teamBId, playerBId] = event.target.value.split(':'); if (playerBId) mutate.mutate({ kind: 'swap', teamAId: team.id, playerAId: slot.playerId, teamBId, playerBId }) }}>Swap⦠{swapOptions.map((candidate) => {names.get(candidate.playerId)} )} {roster.reserve.length > 0 && event.target.value && mutate.mutate({ kind: 'substitute', teamId: team.id, outgoingPlayerId: slot.playerId, reservePlayerId: event.target.value })}>Reserve⦠{roster.reserve.map((id) => {names.get(id) ?? id} )} }>}
+ })})}
{roster.reserve.length > 0 && Reserve {roster.reserve.map((id) => {names.get(id) ?? id} )}
}{mutate.isError && {mutate.error.message}
}
+}
+
+function LegacyBalanceView({ eventId }: { eventId: string }) {
const client = useQueryClient()
const existingTeams = useQuery({ queryKey: ['teams', eventId], queryFn: () => api.teams(eventId), initialData: demoMode ? demoBalanceCandidates[0].teams : undefined })
const generate = useMutation({ mutationFn: () => api.generateBalance(eventId) })
@@ -314,24 +507,45 @@ function LiveSeriesPage() {
const { seriesId } = liveRoute.useParams()
const client = useQueryClient()
const series = useQuery({ queryKey: ['series', seriesId], queryFn: () => api.series(seriesId), initialData: demoMode && seriesId === demoSeries.id ? demoSeries : undefined })
+ const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
const [selected, setSelected] = useState()
- const [mapName, setMapName] = useState('')
const [outcome, setOutcome] = useState('TeamAWin')
const result = useMutation({
- mutationFn: () => api.recordResult(seriesId, mapName, outcome, series.data!.version),
+ mutationFn: () => {
+ const data = series.data!
+ if (demoMode) return api.recordResult(seriesId, `Map ${data.maps.length + 1}`, outcome, data.version)
+ const actingTeamId = session.data?.account.role === 'admin'
+ ? data.teamAlpha.id
+ : data.teamAlpha.captainId === session.data?.player.id ? data.teamAlpha.id
+ : data.teamBeta.captainId === session.data?.player.id ? data.teamBeta.id : ''
+ return api.recordSeriesMap(seriesId, outcome, actingTeamId, data.version)
+ },
onSuccess: (updated) => {
client.setQueryData(['series', seriesId], updated)
- setMapName('')
},
+ onError: () => void series.refetch(),
})
- useEffect(() => subscribeToEvents(`series:${seriesId}`, () => {
- void client.invalidateQueries({ queryKey: ['series', seriesId] })
- }), [client, seriesId])
- if (series.isLoading) return
- if (series.isError) return void series.refetch()} />
- if (!series.data) return
+ const action = useMutation({
+ mutationFn: () => {
+ const data = series.data!
+ const teamId = data.currentStep.activeTeam === 'beta' ? data.teamBeta.id : data.teamAlpha.id
+ if (data.currentStep.kind === 'coin_toss') return api.tossSeriesCoin(seriesId, `${Date.now()}-${crypto.randomUUID()}`, teamId, data.version)
+ if (data.currentStep.kind === 'map_ban') return api.banSeriesMap(seriesId, teamId, selected!, data.version)
+ if (data.currentStep.kind === 'map_pick') return api.pickSeriesMap(seriesId, teamId, selected!, data.version)
+ if (data.currentStep.kind === 'hero_ban') return api.banSeriesHero(seriesId, teamId, selected!, data.version)
+ return Promise.resolve(data)
+ },
+ onSuccess: (updated) => {
+ client.setQueryData(['series', seriesId], updated)
+ setSelected(undefined)
+ },
+ onError: () => void series.refetch(),
+ })
+ if (series.isLoading || session.isLoading) return
+ if (series.isError || session.isError) return { void series.refetch(); void session.refetch() }} />
+ if (!series.data || !session.data) return
const data = series.data
- return Spectator view} />Current step {data.currentStep.title} {data.currentStep.instruction}
{data.currentStep.activeTeam &&
Acting team {data.currentStep.activeTeam === 'alpha' ? data.teamAlpha.name : data.teamBeta.name}
}
{data.currentStep.kind === 'result' ? { e.preventDefault(); result.mutate() }}>Map name setMapName(e.target.value)} placeholder="e.g. Lijiang Tower" /> Outcome setOutcome(e.target.value as MapOutcome)}>{data.teamAlpha.name} wins {data.teamBeta.name} wins Draw {result.isPending ? 'Recordingβ¦' : 'Record result'} {result.isError && {result.error.message}
} : <>{data.options.map((option) => setSelected(option.id)}>{option.role?.slice(0, 1).toUpperCase()} {option.name} {option.disabled ? {option.disabledReason} : } )}
Draft commands use dedicated backend endpoints and server-side validation.
Confirm action >}
+ return Spectator view} />Current step {data.currentStep.title} {data.currentStep.instruction}
{data.currentStep.activeTeam &&
Acting team {data.currentStep.activeTeam === 'alpha' ? data.teamAlpha.name : data.teamBeta.name}
}
{data.currentStep.kind === 'result' ? { e.preventDefault(); result.mutate() }}>Outcome setOutcome(e.target.value as MapOutcome)}>{data.teamAlpha.name} wins {data.teamBeta.name} wins Draw {result.isPending ? 'Recordingβ¦' : 'Record result'} {result.isError && {result.error.message}
} : data.currentStep.kind === 'complete' ? Series completed and bracket updated.
: <>{data.options.map((option) => setSelected(option.id)}>{option.role?.slice(0, 1).toUpperCase()} {option.name} {option.disabled ? {option.disabledReason} : } )}
Draft commands use dedicated backend endpoints and server-side validation.
action.mutate()}>{action.isPending ? 'Confirmingβ¦' : data.currentStep.kind === 'coin_toss' ? 'Toss coin' : 'Confirm action'} {action.isError && {action.error.message}
}>}
}
function Scoreboard({ series }: { series: Series }) {
@@ -344,13 +558,11 @@ function AuditLog({ series }: { series: Series }) { return Connected} />{data.status} {data.currentStep.title} {data.currentStep.instruction}
{demoMode && data.currentStep.kind === 'hero_ban' && {data.maps.at(-1)?.heroBans.map((ban, index) =>
Ban {index + 1} {ban.hero} {ban.role}
)}
}
+ return Connected} />{data.status} {data.currentStep.title} {data.currentStep.instruction}
{data.maps.at(-1)?.heroBans.length ? {data.maps.at(-1)?.heroBans.map((ban, index) =>
Ban {index + 1} {ban.hero} {ban.role}
)}
: null}
}
function BracketPage() {
diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts
index dab7c05..606e228 100644
--- a/frontend/src/api/client.test.ts
+++ b/frontend/src/api/client.test.ts
@@ -8,6 +8,7 @@ const player: RawPlayer = {
ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['Tank'],
preferredPlayerIds: ['player-2'],
+ avoidedPlayerIds: ['player-3'],
createdAt: '2026-07-01T00:00:00Z',
updatedAt: '2026-07-18T00:00:00Z',
}
@@ -21,6 +22,7 @@ describe('backend DTO adapters', () => {
ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['tank'],
preferredPlayerIds: ['player-2'],
+ avoidedPlayerIds: ['player-3'],
})
})
@@ -35,22 +37,39 @@ describe('backend DTO adapters', () => {
createdBy: 'account-1',
createdAt: '2026-07-01T00:00:00Z',
updatedAt: '2026-07-01T00:00:00Z',
+ state: 'RegistrationOpen',
+ version: 0,
+ rulesetId: 'rules-1',
+ activeSeriesId: '',
+ tournamentId: '',
}
const adapted = adaptEvent(event, [
{ eventId: event.id, playerId: player.id, actorAccountId: 'account-1', status: 'Going', source: 'player', updatedAt: event.updatedAt },
], player.id)
expect(adapted.myRsvp).toBe('going')
expect(adapted.counts.going).toBe(1)
+ expect(adaptEvent({ ...event, state: 'Cancelled' }).status).toBe('cancelled')
+ expect(adaptEvent({ ...event, state: 'Completed' }).status).toBe('completed')
+ expect(adaptEvent({ ...event, state: 'Live' }).status).toBe('live')
})
it('keeps corrected results out of the authoritative score', () => {
const series: RawSeries = {
id: 'series-1',
+ eventId: 'event-1',
tournamentId: 't-1',
teamAId: 'a',
teamBId: 'b',
winnerTeamId: '',
bestOf: 3,
+ rulesetId: 'rules-1',
+ phase: 'Playing',
+ currentMap: 'Lijiang',
+ playedMaps: [],
+ nextMapPickerId: '',
+ availableMaps: [],
+ maps: [],
+ audit: [],
version: 2,
results: [
{ mapName: 'Lijiang', outcome: 'TeamAWin', actorAccountId: 'admin', recordedAt: '2026-07-18T00:00:00Z' },
@@ -67,9 +86,10 @@ describe('actual backend routes', () => {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
- await api.updateRatings('Nova', { tank: 37, damage: 29, support: 25 }, ['tank', 'damage'], ['player-2'])
+ await api.updateRatings('Nova', { tank: 37, damage: 29, support: 25 }, ['tank', 'damage'], ['player-2'], ['player-3'])
expect(fetchMock).toHaveBeenCalledWith('/api/me/player', expect.objectContaining({ method: 'PATCH' }))
expect(fetchMock.mock.calls[0][1]?.body).toContain('"preferredRoles":["Tank","Damage"]')
+ expect(fetchMock.mock.calls[0][1]?.body).toContain('"avoidedPlayerIds":["player-3"]')
})
it('sends backend RSVP enum values to the player-specific route', async () => {
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 6af5523..45636f7 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -16,6 +16,7 @@ export interface Player {
ratings: Ratings
preferredRoles: PlayerRole[]
preferredPlayerIds: string[]
+ avoidedPlayerIds: string[]
}
export interface Session {
account: { id: string; discordId: string; displayName: string; avatarUrl?: string; role: AccountRole }
@@ -35,11 +36,17 @@ export interface MixEvent {
startsAt: string
endsAt: string
registrationDeadline: string
- status: 'registration' | 'balancing' | 'live' | 'completed'
+ status: 'registration' | 'balancing' | 'live' | 'completed' | 'cancelled'
rulesetName: string
counts: Record
myRsvp?: RsvpStatus
+ workflowState: EventWorkflowState
+ version: number
+ rulesetId: string
+ activeSeriesId?: string
+ tournamentId?: string
}
+export type EventWorkflowState = 'RegistrationOpen' | 'RegistrationClosed' | 'Balancing' | 'RostersDraft' | 'RostersConfirmed' | 'Live' | 'Completed' | 'Cancelled'
export interface TeamPlayer { player: Player; assignedRole: PlayerRole }
export interface Team {
id: string
@@ -86,7 +93,7 @@ export interface Series {
title: string
instruction: string
activeTeam?: TeamSide
- kind: 'coin_toss' | 'map_ban' | 'hero_ban' | 'result' | 'complete'
+ kind: 'coin_toss' | 'map_ban' | 'map_pick' | 'hero_ban' | 'result' | 'complete'
version: number
}
options: DraftOption[]
@@ -124,12 +131,14 @@ export interface RawPlayer {
id: string; accountId: string; displayName: string; ratings: RawRatings
preferredRoles?: Array<'Tank' | 'Damage' | 'Support'>
preferredPlayerIds?: string[]
+ avoidedPlayerIds?: string[]
createdAt: string; updatedAt: string
}
export interface RawSession { account: RawAccount; player: RawPlayer }
export interface RawEvent {
id: string; name: string; description: string; startsAt: string; endsAt: string
registrationDeadline: string; createdBy: string; createdAt: string; updatedAt: string
+ state: EventWorkflowState; version: number; rulesetId: string; activeSeriesId: string; tournamentId: string
}
export interface RawRSVP {
eventId: string; playerId: string; actorAccountId: string
@@ -140,14 +149,26 @@ export interface RawTeam {
id: string; eventId: string; name: string; captainPlayerId: string; slots: RawSlot[]
}
export interface RawBalanceCandidate { teams: RawTeam[]; reserve: string[]; score: number; explanation?: string[] }
+export interface RawRosterDraft {
+ eventId: string; teams: RawTeam[]; reserve: string[]; version: number; confirmed: boolean
+}
export interface RawMapResult {
mapName: string; actorAccountId: string; outcome: MapOutcome
recordedAt: string; correctionOf?: number
}
export interface RawSeries {
- id: string; tournamentId: string; teamAId: string; teamBId: string
- winnerTeamId: string; bestOf: number; results: RawMapResult[]; version: number
+ id: string; eventId: string; tournamentId: string; teamAId: string; teamBId: string
+ winnerTeamId: string; bestOf: number; rulesetId: string
+ phase: 'CoinTossPending' | 'MapBan' | 'MapPick' | 'HeroBan' | 'Playing' | 'Completed'
+ coinToss?: { seed: string; winnerTeamId: string; performedAt: string }
+ mapDraft?: { pool: string[]; banned: string[]; firstTeamId: string; teamIds: [string, string]; actions: RawDraftAction[] }
+ heroDraft?: { heroes: Array<{ name: string; role: 'Tank' | 'Damage' | 'Support' }>; teamIds: [string, string]; firstTeamId: string; bansPerTeam: number; currentBans: RawDraftAction[] }
+ currentMap: string; playedMaps: string[]; nextMapPickerId: string; availableMaps: string[]
+ maps: Array<{ number: number; name: string; heroBans: RawDraftAction[]; result?: RawMapResult }>
+ audit: Array<{ kind: DraftAction['kind']; summary: string; teamId?: string; actorAccountId: string; at: string }>
+ results: RawMapResult[]; version: number
}
+export interface RawDraftAction { teamId: string; value: string; actorAccountId: string; at: string }
export interface RawTournament {
id: string; eventId: string; name: string; winnerTeamId: string
teamIds: string[]; rounds: RawSeries[][]
@@ -178,6 +199,7 @@ export function adaptPlayer(raw: RawPlayer): Player {
ratings,
preferredRoles,
preferredPlayerIds: raw.preferredPlayerIds ?? [],
+ avoidedPlayerIds: raw.avoidedPlayerIds ?? [],
}
}
export function adaptSession(raw: RawSession): Session {
@@ -194,7 +216,6 @@ export function adaptSession(raw: RawSession): Session {
}
export function adaptEvent(raw: RawEvent, rsvps: RawRSVP[] = [], currentPlayerId?: string): MixEvent {
const now = Date.now()
- const starts = new Date(raw.startsAt).getTime()
const ends = new Date(raw.endsAt).getTime()
const deadline = new Date(raw.registrationDeadline).getTime()
const counts = { going: 0, maybe: 0, not_going: 0 }
@@ -207,10 +228,18 @@ export function adaptEvent(raw: RawEvent, rsvps: RawRSVP[] = [], currentPlayerId
startsAt: raw.startsAt,
endsAt: raw.endsAt,
registrationDeadline: raw.registrationDeadline,
- status: now > ends ? 'completed' : now >= starts ? 'live' : now > deadline ? 'balancing' : 'registration',
+ status: raw.state === 'Cancelled' ? 'cancelled'
+ : raw.state === 'Completed' || now > ends ? 'completed'
+ : raw.state === 'Live' ? 'live'
+ : raw.state === 'RegistrationOpen' && now <= deadline ? 'registration' : 'balancing',
rulesetName: 'Server ruleset',
counts,
myRsvp: own ? statusFromRaw(own.status) : undefined,
+ workflowState: raw.state,
+ version: raw.version,
+ rulesetId: raw.rulesetId,
+ activeSeriesId: raw.activeSeriesId || undefined,
+ tournamentId: raw.tournamentId || undefined,
}
}
export function adaptRegistration(raw: RawRSVP, players: Map, session?: Session): Registration {
@@ -220,6 +249,7 @@ export function adaptRegistration(raw: RawRSVP, players: Map, se
ratings: { tank: 1, damage: 1, support: 1, updatedAt: raw.updatedAt },
preferredRoles: [],
preferredPlayerIds: [],
+ avoidedPlayerIds: [],
}
return {
id: `${raw.eventId}:${raw.playerId}`,
@@ -246,6 +276,7 @@ export function adaptTeam(raw: RawTeam, players: Map, index = 0)
},
preferredRoles: [roleFromRaw(slot.role)],
preferredPlayerIds: [],
+ avoidedPlayerIds: [],
},
assignedRole: roleFromRaw(slot.role),
}))
@@ -294,38 +325,54 @@ export function adaptSeries(raw: RawSeries, teams: Team[] = []): Series {
alpha: total.alpha + (result.outcome === 'TeamAWin' ? 1 : 0),
beta: total.beta + (result.outcome === 'TeamBWin' ? 1 : 0),
}), { alpha: 0, beta: 0 })
- const complete = Boolean(raw.winnerTeamId)
+ const complete = raw.phase === 'Completed' || Boolean(raw.winnerTeamId)
+ const phase = raw.phase ?? (complete ? 'Completed' : 'Playing')
+ const status: Series['status'] = phase === 'CoinTossPending' ? 'coin_toss'
+ : phase === 'MapBan' || phase === 'MapPick' ? 'map_draft'
+ : phase === 'HeroBan' ? 'hero_draft'
+ : phase === 'Completed' ? 'completed' : 'playing'
+ const nextMapTeam = raw.mapDraft
+ ? (raw.mapDraft.actions.length % 2 === 0 ? raw.mapDraft.firstTeamId : raw.mapDraft.teamIds.find((id) => id !== raw.mapDraft?.firstTeamId))
+ : undefined
+ const nextHeroTeam = raw.heroDraft
+ ? (raw.heroDraft.currentBans.length % 2 === 0 ? raw.heroDraft.firstTeamId : raw.heroDraft.teamIds.find((id) => id !== raw.heroDraft?.firstTeamId))
+ : undefined
+ const activeTeamId = phase === 'MapBan' ? nextMapTeam : phase === 'MapPick' ? raw.nextMapPickerId : phase === 'HeroBan' ? nextHeroTeam : undefined
+ const kind: Series['currentStep']['kind'] = phase === 'CoinTossPending' ? 'coin_toss'
+ : phase === 'MapBan' ? 'map_ban' : phase === 'MapPick' ? 'map_pick' : phase === 'HeroBan' ? 'hero_ban' : phase === 'Completed' ? 'complete' : 'result'
+ const options: DraftOption[] = phase === 'MapBan' && raw.mapDraft
+ ? raw.mapDraft.pool.filter((name) => !raw.mapDraft?.banned.includes(name)).map((name) => ({ id: name, name }))
+ : phase === 'MapPick'
+ ? (raw.availableMaps ?? []).map((name) => ({ id: name, name }))
+ : phase === 'HeroBan' && raw.heroDraft
+ ? raw.heroDraft.heroes.filter((hero) => !raw.heroDraft?.currentBans.some((ban) => ban.value === hero.name)).map((hero) => ({ id: hero.name, name: hero.name, role: roleFromRaw(hero.role) }))
+ : []
return {
id: raw.id,
tournamentId: raw.tournamentId,
- status: complete ? 'completed' : raw.teamAId && raw.teamBId ? 'playing' : 'scheduled',
+ status,
roundLabel: 'Tournament series',
teamAlpha: alpha,
teamBeta: beta,
score,
version: raw.version,
currentStep: {
- title: complete ? 'Series complete' : `Record map ${activeResults.length + 1}`,
- instruction: complete ? 'The tournament bracket has advanced.' : 'An admin can record the authoritative map result.',
- kind: complete ? 'complete' : 'result',
+ title: phase === 'CoinTossPending' ? 'Coin toss' : phase === 'MapBan' ? 'Map bans' : phase === 'MapPick' ? 'Loser picks the next map' : phase === 'HeroBan' ? 'Hero bans' : complete ? 'Series complete' : `Record ${raw.currentMap || `map ${activeResults.length + 1}`}`,
+ instruction: complete ? 'The series is complete.' : phase === 'Playing' ? 'Record the authoritative map result.' : 'The active captain or an admin can act.',
+ activeTeam: activeTeamId ? (activeTeamId === raw.teamAId ? 'alpha' : 'beta') : undefined,
+ kind,
version: raw.version,
},
- options: [],
- maps: activeResults.map((result, index) => ({
- number: index + 1,
- name: result.mapName,
+ options,
+ maps: (raw.maps ?? activeResults.map((result, index) => ({ number: index + 1, name: result.mapName, heroBans: [], result }))).map((map) => ({
+ number: map.number,
+ name: map.name,
mode: 'Map',
- status: 'completed',
- winner: result.outcome === 'TeamAWin' ? 'alpha' : result.outcome === 'TeamBWin' ? 'beta' : 'draw',
- heroBans: [],
- })),
- audit: raw.results.map((result, index) => ({
- id: `result-${index}`,
- kind: 'result',
- summary: `${result.mapName}: ${result.outcome === 'TeamAWin' ? alpha.name : result.outcome === 'TeamBWin' ? beta.name : 'Draw'}`,
- actorName: result.actorAccountId,
- createdAt: result.recordedAt,
+ status: map.result ? 'completed' : phase === 'Playing' ? 'playing' : phase === 'HeroBan' ? 'ready' : 'drafting',
+ winner: map.result ? (map.result.outcome === 'TeamAWin' ? 'alpha' : map.result.outcome === 'TeamBWin' ? 'beta' : 'draw') : undefined,
+ heroBans: map.heroBans.map((ban) => ({ hero: ban.value, team: ban.teamId === raw.teamAId ? 'alpha' : 'beta', role: 'damage' as PlayerRole })),
})),
+ audit: (raw.audit ?? []).map((action, index) => ({ id: `action-${index}`, kind: action.kind, summary: action.summary, actorName: action.actorAccountId, createdAt: action.at })),
}
}
export function adaptTournament(raw: RawTournament, teams: Team[] = []): Bracket {
@@ -399,9 +446,8 @@ const raw = {
}
async function adaptSeriesWithTeams(series: RawSeries) {
- if (!series.tournamentId) return adaptSeries(series)
- const tournament = await request(`/tournaments/${series.tournamentId}`)
- const [teams, players] = await Promise.all([raw.teams(tournament.eventId), raw.players()])
+ const eventId = series.eventId || (series.tournamentId ? (await request(`/tournaments/${series.tournamentId}`)).eventId : '')
+ const [teams, players] = await Promise.all([raw.teams(eventId), raw.players()])
const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player]))
return adaptSeries(series, teams.map((team, index) => adaptTeam(team, byId, index)))
}
@@ -415,6 +461,7 @@ export const api = {
ratings: Pick,
preferredRoles: PlayerRole[],
preferredPlayerIds: string[],
+ avoidedPlayerIds: string[],
) =>
adaptPlayer(await request('/me/player', {
method: 'PATCH',
@@ -423,6 +470,7 @@ export const api = {
ratings: { tank: ratings.tank, damage: ratings.damage, support: ratings.support },
preferredRoles: preferredRoles.map((role) => role === 'tank' ? 'Tank' : role === 'damage' ? 'Damage' : 'Support'),
preferredPlayerIds,
+ avoidedPlayerIds,
}),
})),
players: async () => (await raw.players()).map(adaptPlayer),
@@ -467,8 +515,13 @@ export const api = {
])
return adaptRegistration(result, new Map(players.map(adaptPlayer).map((player) => [player.id, player])), adaptSession(session))
},
+ removeParticipant: (eventId: string, playerId: string) =>
+ request(`/events/${eventId}/rsvps/${playerId}`, { method: 'DELETE' }),
createEvent: async (event: EventInput) => adaptEvent(await request('/events', { method: 'POST', body: JSON.stringify(event) })),
updateEvent: async (eventId: string, event: EventInput) => adaptEvent(await request(`/events/${eventId}`, { method: 'PUT', body: JSON.stringify(event) })),
+ cancelEvent: async (eventId: string, expectedVersion: number) =>
+ adaptEvent(await request(`/events/${eventId}/cancel`, { method: 'POST', body: JSON.stringify({ expectedVersion }) })),
+ deleteEvent: (eventId: string) => request(`/events/${eventId}`, { method: 'DELETE' }),
generateBalance: async (eventId: string) => {
const [candidates, players] = await Promise.all([
request(`/events/${eventId}/balance`, { method: 'POST' }),
@@ -477,6 +530,50 @@ export const api = {
const adaptedPlayers = players.map(adaptPlayer)
return candidates.map((candidate, index) => adaptBalance(candidate, adaptedPlayers, index))
},
+ closeRegistration: async (eventId: string, rulesetId: string, expectedVersion: number) =>
+ adaptEvent(await request(`/events/${eventId}/registration/close`, {
+ method: 'POST',
+ body: JSON.stringify({ rulesetId, expectedVersion }),
+ })),
+ generateWorkflowBalance: async (eventId: string, expectedVersion: number) => {
+ const [result, players] = await Promise.all([
+ request<{ event: RawEvent; candidates: RawBalanceCandidate[] }>(`/events/${eventId}/balance/generate`, {
+ method: 'POST',
+ body: JSON.stringify({ expectedVersion }),
+ }),
+ raw.players(),
+ ])
+ const adaptedPlayers = players.map(adaptPlayer)
+ return {
+ event: adaptEvent(result.event),
+ candidates: result.candidates.map((candidate, index) => adaptBalance(candidate, adaptedPlayers, index)),
+ }
+ },
+ roster: (eventId: string) => request(`/events/${eventId}/roster`),
+ selectWorkflowBalance: (eventId: string, candidate: BalanceCandidate, expectedVersion: number) =>
+ request(`/events/${eventId}/roster`, {
+ method: 'PUT',
+ body: JSON.stringify({ candidate: candidate.payload, expectedVersion }),
+ }),
+ swapRoster: (eventId: string, input: { teamAId: string; playerAId: string; teamBId: string; playerBId: string; expectedVersion: number }) =>
+ request(`/events/${eventId}/roster/swap`, { method: 'POST', body: JSON.stringify(input) }),
+ substituteRoster: (eventId: string, input: { teamId: string; outgoingPlayerId: string; reservePlayerId: string; expectedVersion: number }, emergency = false) =>
+ request(`/events/${eventId}/roster/${emergency ? 'emergency-substitute' : 'substitute'}`, { method: 'POST', body: JSON.stringify(input) }),
+ setRosterCaptain: (eventId: string, teamId: string, playerId: string, expectedVersion: number) =>
+ request(`/events/${eventId}/roster/captain`, {
+ method: 'PUT',
+ body: JSON.stringify({ teamId, playerId, expectedVersion }),
+ }),
+ confirmRosters: (eventId: string, expectedVersion: number, expectedRosterVersion: number) =>
+ request(`/events/${eventId}/roster/confirm`, {
+ method: 'POST',
+ body: JSON.stringify({ expectedVersion, expectedRosterVersion }),
+ }),
+ startScrim: (eventId: string, expectedVersion: number) =>
+ request<{ event: RawEvent; series?: RawSeries; tournament?: RawTournament }>(`/events/${eventId}/start`, {
+ method: 'POST',
+ body: JSON.stringify({ expectedVersion }),
+ }),
selectBalance: (eventId: string, candidate: BalanceCandidate) =>
request<{ status: string }>(`/events/${eventId}/teams`, {
method: 'PUT',
@@ -512,6 +609,31 @@ export const api = {
method: 'POST',
body: JSON.stringify({ mapName, outcome, expectedVersion }),
})),
+ tossSeriesCoin: async (seriesId: string, seed: string, actingTeamId: string, expectedVersion: number) =>
+ adaptSeriesWithTeams(await request(`/series/${seriesId}/coin-toss`, {
+ method: 'POST',
+ body: JSON.stringify({ seed, actingTeamId, expectedVersion }),
+ })),
+ banSeriesMap: async (seriesId: string, teamId: string, map: string, expectedVersion: number) =>
+ adaptSeriesWithTeams(await request(`/series/${seriesId}/map-bans`, {
+ method: 'POST',
+ body: JSON.stringify({ teamId, map, expectedVersion }),
+ })),
+ pickSeriesMap: async (seriesId: string, teamId: string, map: string, expectedVersion: number) =>
+ adaptSeriesWithTeams(await request(`/series/${seriesId}/map-pick`, {
+ method: 'POST',
+ body: JSON.stringify({ teamId, map, expectedVersion }),
+ })),
+ banSeriesHero: async (seriesId: string, teamId: string, hero: string, expectedVersion: number) =>
+ adaptSeriesWithTeams(await request(`/series/${seriesId}/hero-bans`, {
+ method: 'POST',
+ body: JSON.stringify({ teamId, hero, expectedVersion }),
+ })),
+ recordSeriesMap: async (seriesId: string, outcome: MapOutcome, actingTeamId: string, expectedVersion: number) =>
+ adaptSeriesWithTeams(await request(`/series/${seriesId}/map-result`, {
+ method: 'POST',
+ body: JSON.stringify({ outcome, actingTeamId, expectedVersion }),
+ })),
correctResult: async (seriesId: string, index: number, mapName: string, outcome: MapOutcome, expectedVersion: number) =>
adaptSeriesWithTeams(await request(`/series/${seriesId}/results/${index}`, {
method: 'PATCH',
@@ -536,6 +658,7 @@ export function subscribeToEvents(
onMessage: (event: RawRealtimeEvent) => void,
onStatus?: (status: 'connected' | 'reconnecting') => void,
) {
+ if (typeof EventSource === 'undefined') return () => undefined
const source = new EventSource(`${API_BASE}/events/stream?topic=${encodeURIComponent(topic)}`, { withCredentials: true })
source.onopen = () => onStatus?.('connected')
source.onerror = () => onStatus?.('reconnecting')
diff --git a/frontend/src/api/demo.ts b/frontend/src/api/demo.ts
index 86843a0..b42cc08 100644
--- a/frontend/src/api/demo.ts
+++ b/frontend/src/api/demo.ts
@@ -31,11 +31,13 @@ const makePlayer = (
support: RankOrdinal,
): Player => ({
id,
+ accountId: `account-${id}`,
displayName,
battleTag: `${displayName.replace(/\s/g, '')}#${1000 + Number(id.replace(/\D/g, '') || 0)}`,
ratings: { tank, damage, support, updatedAt: daysFromNow(-4) },
preferredRoles: tank >= damage && tank >= support ? ['tank'] : damage >= support ? ['damage'] : ['support'],
preferredPlayerIds: [],
+ avoidedPlayerIds: [],
})
export const demoProfile: Player = {
@@ -66,6 +68,9 @@ export const demoEvents: MixEvent[] = [
rulesetName: 'Community Bo3 v2',
counts: { going: 18, maybe: 4, not_going: 3 },
myRsvp: 'going',
+ workflowState: 'RegistrationOpen',
+ version: 0,
+ rulesetId: 'standard-control-hybrid-control',
},
{
id: 'event-2',
@@ -78,6 +83,9 @@ export const demoEvents: MixEvent[] = [
rulesetName: 'Quick Mix v1',
counts: { going: 9, maybe: 6, not_going: 2 },
myRsvp: 'maybe',
+ workflowState: 'RegistrationOpen',
+ version: 0,
+ rulesetId: 'standard-control-push-escort',
},
{
id: 'event-3',
@@ -90,6 +98,38 @@ export const demoEvents: MixEvent[] = [
rulesetName: 'Tournament Bo3 v2',
counts: { going: 22, maybe: 0, not_going: 4 },
myRsvp: 'going',
+ workflowState: 'Live',
+ version: 6,
+ rulesetId: 'standard-control-hybrid-control',
+ activeSeriesId: 'series-1',
+ },
+ {
+ id: 'event-4',
+ title: 'Cancelled Community Mix',
+ description: 'This event was cancelled by the organizer',
+ startsAt: daysFromNow(7),
+ endsAt: daysFromNow(7, 23),
+ registrationDeadline: daysFromNow(6),
+ status: 'cancelled',
+ rulesetName: 'Community Bo3 v2',
+ counts: { going: 7, maybe: 2, not_going: 1 },
+ workflowState: 'Cancelled',
+ version: 2,
+ rulesetId: 'standard-control-hybrid-control',
+ },
+ {
+ id: 'event-5',
+ title: 'Completed Friday Mix',
+ description: 'Finished Bo3 community scrim',
+ startsAt: daysFromNow(-3),
+ endsAt: daysFromNow(-3, 23),
+ registrationDeadline: daysFromNow(-4),
+ status: 'completed',
+ rulesetName: 'Community Bo3 v2',
+ counts: { going: 10, maybe: 0, not_going: 1 },
+ workflowState: 'Completed',
+ version: 12,
+ rulesetId: 'standard-control-hybrid-control',
},
]
@@ -109,6 +149,7 @@ const players = [
]
players[1].preferredPlayerIds = ['p1']
players[4].preferredPlayerIds = ['p1']
+players[0].avoidedPlayerIds = ['p3']
export const demoRegistrations: Registration[] = players.map((player, index) => ({
id: `reg-${index + 1}`,
diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx
index 25d20ba..b7f9175 100644
--- a/frontend/src/i18n.tsx
+++ b/frontend/src/i18n.tsx
@@ -7,6 +7,7 @@ const translations: Record = {
'Bracket': 'Π‘Π΅ΡΠΊΠ°',
'Admin': 'ΠΠ΄ΠΌΠΈΠ½',
'Profile': 'ΠΡΠΎΡΠΈΠ»Ρ',
+ 'FAQ': 'FAQ',
'Language': 'Π―Π·ΡΠΊ',
'Primary navigation': 'ΠΡΠ½ΠΎΠ²Π½Π°Ρ Π½Π°Π²ΠΈΠ³Π°ΡΠΈΡ',
'Mobile navigation': 'ΠΠΎΠ±ΠΈΠ»ΡΠ½Π°Ρ Π½Π°Π²ΠΈΠ³Π°ΡΠΈΡ',
@@ -15,6 +16,7 @@ const translations: Record = {
'Sign out': 'ΠΡΠΉΡΠΈ',
'Toggle navigation': 'ΠΡΠΊΡΡΡΡ Π½Π°Π²ΠΈΠ³Π°ΡΠΈΡ',
'Realtime connected': 'ΠΠ½Π»Π°ΠΉΠ½-ΡΠΎΠ΅Π΄ΠΈΠ½Π΅Π½ΠΈΠ΅ ΡΡΡΠ°Π½ΠΎΠ²Π»Π΅Π½ΠΎ',
+ 'Realtime reconnecting': 'ΠΠ΅ΡΠ΅ΠΏΠΎΠ΄ΠΊΠ»ΡΡΠ΅Π½ΠΈΠ΅ ΠΊ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΡΠΌ',
'Community scrims, organized': 'ΠΡΠ³Π°Π½ΠΈΠ·ΠΎΠ²Π°Π½Π½ΡΠ΅ ΠΊΠΎΠΌΠ°Π½Π΄Π½ΡΠ΅ ΠΌΠΈΠΊΡΡ',
'Ready up.': 'ΠΡΠΈΠ³ΠΎΡΠΎΠ²ΡΡΠ΅ΡΡ.',
'Play together.': 'ΠΠ³ΡΠ°ΠΉΡΠ΅ Π²ΠΌΠ΅ΡΡΠ΅.',
@@ -88,6 +90,8 @@ const translations: Record = {
'Choose every role you enjoy playing.': 'ΠΡΠ±Π΅ΡΠΈΡΠ΅ Π²ΡΠ΅ ΡΠΎΠ»ΠΈ, Π½Π° ΠΊΠΎΡΠΎΡΡΡ
Π²Π°ΠΌ Π½ΡΠ°Π²ΠΈΡΡΡ ΠΈΠ³ΡΠ°ΡΡ.',
'Preferred teammates': 'ΠΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅ΠΌΡΠ΅ Π½Π°ΠΏΠ°ΡΠ½ΠΈΠΊΠΈ',
'Choose up to 3 players. The balancer treats these as preferences, not guarantees.': 'ΠΡΠ±Π΅ΡΠΈΡΠ΅ Π΄ΠΎ 3 ΠΈΠ³ΡΠΎΠΊΠΎΠ². ΠΠ°Π»Π°Π½ΡΠΈΡΠΎΠ²ΡΠΈΠΊ ΡΡΠΈΡΡΠ²Π°Π΅Ρ ΡΡΠΎ ΠΊΠ°ΠΊ ΠΏΠΎΠΆΠ΅Π»Π°Π½ΠΈΠ΅, Π° Π½Π΅ Π³Π°ΡΠ°Π½ΡΠΈΡ.',
+ 'Avoided teammates': 'ΠΠ·Π±Π΅Π³Π°Π΅ΠΌΡΠ΅ Π½Π°ΠΏΠ°ΡΠ½ΠΈΠΊΠΈ',
+ 'Choose up to 3 players. This is a soft preference, so balance can still place you together when necessary.': 'ΠΡΠ±Π΅ΡΠΈΡΠ΅ Π΄ΠΎ 3 ΠΈΠ³ΡΠΎΠΊΠΎΠ². ΠΡΠΎ ΠΌΡΠ³ΠΊΠΎΠ΅ ΠΏΡΠ΅Π΄ΠΏΠΎΡΡΠ΅Π½ΠΈΠ΅, ΠΏΠΎΡΡΠΎΠΌΡ ΠΏΡΠΈ Π½Π΅ΠΎΠ±Ρ
ΠΎΠ΄ΠΈΠΌΠΎΡΡΠΈ Π±Π°Π»Π°Π½ΡΠΈΡΠΎΠ²ΡΠΈΠΊ Π²ΡΡ Π΅ΡΡ ΠΌΠΎΠΆΠ΅Ρ ΠΏΠΎΡΡΠ°Π²ΠΈΡΡ Π²Π°Ρ Π²ΠΌΠ΅ΡΡΠ΅.',
'Loading admin control roomβ¦': 'ΠΠ°Π³ΡΡΠ·ΠΊΠ° ΠΏΠ°Π½Π΅Π»ΠΈ Π°Π΄ΠΌΠΈΠ½ΠΈΡΡΡΠ°ΡΠΎΡΠ°β¦',
'Admin control room': 'ΠΠ°Π½Π΅Π»Ρ Π°Π΄ΠΌΠΈΠ½ΠΈΡΡΡΠ°ΡΠΎΡΠ°',
'Create event': 'Π‘ΠΎΠ·Π΄Π°ΡΡ ΡΠΎΠ±ΡΡΠΈΠ΅',
@@ -101,6 +105,11 @@ const translations: Record = {
'Event settings': 'ΠΠ°ΡΡΡΠΎΠΉΠΊΠΈ ΡΠΎΠ±ΡΡΠΈΡ',
'Scheduling and registration': 'Π Π°ΡΠΏΠΈΡΠ°Π½ΠΈΠ΅ ΠΈ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΡ',
'Published': 'ΠΠΏΡΠ±Π»ΠΈΠΊΠΎΠ²Π°Π½ΠΎ',
+ 'Cancelled': 'ΠΡΠΌΠ΅Π½Π΅Π½ΠΎ',
+ 'Completed': 'ΠΠ°Π²Π΅ΡΡΠ΅Π½ΠΎ',
+ 'Cancel event': 'ΠΡΠΌΠ΅Π½ΠΈΡΡ ΡΠΎΠ±ΡΡΠΈΠ΅',
+ 'Delete event': 'Π£Π΄Π°Π»ΠΈΡΡ ΡΠΎΠ±ΡΡΠΈΠ΅',
+ 'LIVE': 'LIVE',
'Event name': 'ΠΠ°Π·Π²Π°Π½ΠΈΠ΅ ΡΠΎΠ±ΡΡΠΈΡ',
'Start time': 'ΠΡΠ΅ΠΌΡ Π½Π°ΡΠ°Π»Π°',
'End time': 'ΠΡΠ΅ΠΌΡ ΠΎΠΊΠΎΠ½ΡΠ°Π½ΠΈΡ',
@@ -124,6 +133,7 @@ const translations: Record = {
'Support rank': 'Π Π°Π½Π³ ΠΏΠΎΠ΄Π΄Π΅ΡΠΆΠΊΠΈ',
'Status': 'Π‘ΡΠ°ΡΡΡ',
'Addingβ¦': 'ΠΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΠ΅β¦',
+ 'Remove from event': 'Π£Π΄Π°Π»ΠΈΡΡ ΠΈΠ· ΠΌΠΈΠΊΡΠ°',
'Not going': 'ΠΠ΅ ΠΈΠ΄ΡΡ',
'Preferred with': 'ΠΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅Ρ ΠΈΠ³ΡΠ°ΡΡ Ρ',
'No preferred roles': 'ΠΠ΅Ρ ΠΏΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅ΠΌΡΡ
ΡΠΎΠ»Π΅ΠΉ',
@@ -191,6 +201,52 @@ const translations: Record = {
'map_draft': 'Π±Π°Π½ ΠΊΠ°ΡΡ',
'coin_toss': 'ΠΆΠ΅ΡΠ΅Π±ΡΡΠ²ΠΊΠ°',
'Map': 'ΠΠ°ΡΡΠ°',
+ 'Close registration': 'ΠΠ°ΠΊΡΡΡΡ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΡ',
+ 'Freeze the Going participant list before balancing.': 'ΠΠ°ΡΠΈΠΊΡΠΈΡΠΎΠ²Π°ΡΡ ΡΠΏΠΈΡΠΎΠΊ ΡΡΠ°ΡΡΠ½ΠΈΠΊΠΎΠ² ΠΏΠ΅ΡΠ΅Π΄ Π±Π°Π»Π°Π½ΡΠΈΡΠΎΠ²ΠΊΠΎΠΉ.',
+ 'Generate fair teams': 'Π‘Π±Π°Π»Π°Π½ΡΠΈΡΠΎΠ²Π°ΡΡ ΠΊΠΎΠΌΠ°Π½Π΄Ρ',
+ 'The optimizer considers ranks, preferred roles, and preferred teammates.': 'ΠΠΏΡΠΈΠΌΠΈΠ·Π°ΡΠΎΡ ΡΡΠΈΡΡΠ²Π°Π΅Ρ ΡΠ°Π½Π³ΠΈ, ΠΏΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅ΠΌΡΠ΅ ΡΠΎΠ»ΠΈ ΠΈ Π½Π°ΠΏΠ°ΡΠ½ΠΈΠΊΠΎΠ².',
+ 'Generate balance': 'Π‘Π³Π΅Π½Π΅ΡΠΈΡΠΎΠ²Π°ΡΡ Π±Π°Π»Π°Π½Ρ',
+ 'Generate candidates': 'Π‘Π³Π΅Π½Π΅ΡΠΈΡΠΎΠ²Π°ΡΡ Π²Π°ΡΠΈΠ°Π½ΡΡ',
+ 'Roster editor': 'Π Π΅Π΄Π°ΠΊΡΠΎΡ ΡΠΎΡΡΠ°Π²ΠΎΠ²',
+ 'Captain': 'ΠΠ°ΠΏΠΈΡΠ°Π½',
+ 'Choose captain': 'ΠΡΠ±ΡΠ°ΡΡ ΠΊΠ°ΠΏΠΈΡΠ°Π½Π°',
+ 'Swapβ¦': 'ΠΠ±ΠΌΠ΅Π½ΡΡΡβ¦',
+ 'Reserveβ¦': 'ΠΠ· ΡΠ΅Π·Π΅ΡΠ²Π°β¦',
+ 'Confirm rosters': 'ΠΠΎΠ΄ΡΠ²Π΅ΡΠ΄ΠΈΡΡ ΡΠΎΡΡΠ°Π²Ρ',
+ 'Every team must have a valid 1/2/2 lineup and a captain.': 'Π ΠΊΠ°ΠΆΠ΄ΠΎΠΉ ΠΊΠΎΠΌΠ°Π½Π΄Π΅ Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±ΡΡΡ ΡΠΎΡΡΠ°Π² 1/2/2 ΠΈ ΠΊΠ°ΠΏΠΈΡΠ°Π½.',
+ 'Start scrim': 'ΠΠ°ΡΠ°ΡΡ ΡΠΊΡΠΈΠΌ',
+ 'This atomically creates the Bo3 series or tournament bracket.': 'Π‘Π΅ΡΠΈΡ Bo3 ΠΈΠ»ΠΈ ΡΡΡΠ½ΠΈΡΠ½Π°Ρ ΡΠ΅ΡΠΊΠ° Π±ΡΠ΄ΡΡ ΡΠΎΠ·Π΄Π°Π½Ρ Π°ΡΠΎΠΌΠ°ΡΠ½ΠΎ.',
+ 'Scrim is live': 'Π‘ΠΊΡΠΈΠΌ Π½Π°ΡΠ°Π»ΡΡ',
+ 'Coin toss, bans, and map results are now available.': 'Π’Π΅ΠΏΠ΅ΡΡ Π΄ΠΎΡΡΡΠΏΠ½Ρ ΠΆΠ΅ΡΠ΅Π±ΡΡΠ²ΠΊΠ°, Π±Π°Π½Ρ ΠΈ ΡΠ΅Π·ΡΠ»ΡΡΠ°ΡΡ ΠΊΠ°ΡΡ.',
+ 'Open live series': 'ΠΡΠΊΡΡΡΡ ΡΠ΅ΡΠΈΡ',
+ 'Open bracket': 'ΠΡΠΊΡΡΡΡ ΡΠ΅ΡΠΊΡ',
+ 'Scrim completed': 'Π‘ΠΊΡΠΈΠΌ Π·Π°Π²Π΅ΡΡΡΠ½',
+ 'The final result and full audit trail are preserved.': 'ΠΡΠΎΠ³ΠΎΠ²ΡΠΉ ΡΠ΅Π·ΡΠ»ΡΡΠ°Ρ ΠΈ ΠΏΠΎΠ»Π½ΡΠΉ ΠΆΡΡΠ½Π°Π» Π΄Π΅ΠΉΡΡΠ²ΠΈΠΉ ΡΠΎΡ
ΡΠ°Π½Π΅Π½Ρ.',
+ 'Registration has been closed by the organizer.': 'ΠΡΠ³Π°Π½ΠΈΠ·Π°ΡΠΎΡ Π·Π°ΠΊΡΡΠ» ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΡ.',
+ 'Toss coin': 'ΠΠΎΠ΄Π±ΡΠΎΡΠΈΡΡ ΠΌΠΎΠ½Π΅ΡΡ',
+ 'Confirmingβ¦': 'ΠΠΎΠ΄ΡΠ²Π΅ΡΠΆΠ΄Π΅Π½ΠΈΠ΅β¦',
+ 'Workingβ¦': 'ΠΡΠΏΠΎΠ»Π½ΡΠ΅ΡΡΡβ¦',
+ 'Series completed and bracket updated.': 'Π‘Π΅ΡΠΈΡ Π·Π°Π²Π΅ΡΡΠ΅Π½Π°, ΡΠ΅ΡΠΊΠ° ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½Π°.',
+ 'Loser picks the next map': 'ΠΡΠΎΠΈΠ³ΡΠ°Π²ΡΠ°Ρ ΠΊΠΎΠΌΠ°Π½Π΄Π° Π²ΡΠ±ΠΈΡΠ°Π΅Ρ ΡΠ»Π΅Π΄ΡΡΡΡΡ ΠΊΠ°ΡΡΡ',
+ 'Rules and help': 'ΠΡΠ°Π²ΠΈΠ»Π° ΠΈ ΠΏΠΎΠΌΠΎΡΡ',
+ 'Frequently asked questions': 'Π§Π°ΡΡΡΠ΅ Π²ΠΎΠΏΡΠΎΡΡ',
+ 'Everything players and captains need before the scrim starts.': 'ΠΡΡ, ΡΡΠΎ Π½ΡΠΆΠ½ΠΎ Π·Π½Π°ΡΡ ΠΈΠ³ΡΠΎΠΊΠ°ΠΌ ΠΈ ΠΊΠ°ΠΏΠΈΡΠ°Π½Π°ΠΌ Π΄ΠΎ Π½Π°ΡΠ°Π»Π° ΡΠΊΡΠΈΠΌΠ°.',
+ 'How are teams balanced?': 'ΠΠ°ΠΊ Π±Π°Π»Π°Π½ΡΠΈΡΡΡΡΡΡ ΠΊΠΎΠΌΠ°Π½Π΄Ρ?',
+ 'The server builds role-locked 1 Tank / 2 Damage / 2 Support teams. Rank balance is the main goal. Preferred roles, preferred teammates, and up to three avoided teammates are soft preferences and never override a fair valid roster.': 'Π‘Π΅ΡΠ²Π΅Ρ ΡΠΎΠ±ΠΈΡΠ°Π΅Ρ ΠΊΠΎΠΌΠ°Π½Π΄Ρ 1 ΡΠ°Π½ΠΊ / 2 ΡΡΠΎΠ½Π° / 2 ΠΏΠΎΠ΄Π΄Π΅ΡΠΆΠΊΠΈ. ΠΠ»Π°Π²Π½Π°Ρ ΡΠ΅Π»Ρ β Π±Π°Π»Π°Π½Ρ ΡΠ°Π½Π³ΠΎΠ². ΠΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅ΠΌΡΠ΅ ΡΠΎΠ»ΠΈ, Π½Π°ΠΏΠ°ΡΠ½ΠΈΠΊΠΈ ΠΈ Π΄ΠΎ ΡΡΡΡ
avoid-ΠΈΠ³ΡΠΎΠΊΠΎΠ² ΡΠ²Π»ΡΡΡΡΡ ΠΌΡΠ³ΠΊΠΈΠΌΠΈ ΠΏΠΎΠΆΠ΅Π»Π°Π½ΠΈΡΠΌΠΈ ΠΈ Π½Π΅ Π²Π°ΠΆΠ½Π΅Π΅ ΡΠ΅ΡΡΠ½ΠΎΠ³ΠΎ ΠΊΠΎΡΡΠ΅ΠΊΡΠ½ΠΎΠ³ΠΎ ΡΠΎΡΡΠ°Π²Π°.',
+ 'How does registration work?': 'ΠΠ°ΠΊ ΡΠ°Π±ΠΎΡΠ°Π΅Ρ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΡ?',
+ 'Choose Going, Maybe, or Canβt go before registration closes. Only Going players enter balancing. After an admin closes registration, normal RSVP changes are locked.': 'ΠΠΎ Π·Π°ΠΊΡΡΡΠΈΡ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΠΈ Π²ΡΠ±Π΅ΡΠΈΡΠ΅ Β«ΠΠ΄ΡΒ», Β«ΠΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎΒ» ΠΈΠ»ΠΈ Β«ΠΠ΅ ΠΈΠ΄ΡΒ». Π Π±Π°Π»Π°Π½ΡΠΈΡΠΎΠ²ΠΊΡ ΠΏΠΎΠΏΠ°Π΄Π°ΡΡ ΡΠΎΠ»ΡΠΊΠΎ ΠΈΠ³ΡΠΎΠΊΠΈ ΡΠΎ ΡΡΠ°ΡΡΡΠΎΠΌ Β«ΠΠ΄ΡΒ». ΠΠΎΡΠ»Π΅ Π·Π°ΠΊΡΡΡΠΈΡ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΠΈ ΠΎΠ±ΡΡΠ½ΡΠ΅ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΡ Π±Π»ΠΎΠΊΠΈΡΡΡΡΡΡ.',
+ 'What happens before the scrim?': 'Π§ΡΠΎ ΠΏΡΠΎΠΈΡΡ
ΠΎΠ΄ΠΈΡ ΠΏΠ΅ΡΠ΅Π΄ ΡΠΊΡΠΈΠΌΠΎΠΌ?',
+ 'An admin generates balance candidates, chooses one, may swap players of the same role or use the reserve, assigns captains, confirms the rosters, and starts the scrim.': 'ΠΠ΄ΠΌΠΈΠ½ΠΈΡΡΡΠ°ΡΠΎΡ ΡΠΎΠ·Π΄Π°ΡΡ Π²Π°ΡΠΈΠ°Π½ΡΡ Π±Π°Π»Π°Π½ΡΠ°, Π²ΡΠ±ΠΈΡΠ°Π΅Ρ ΠΎΠ΄ΠΈΠ½, ΠΏΡΠΈ Π½Π΅ΠΎΠ±Ρ
ΠΎΠ΄ΠΈΠΌΠΎΡΡΠΈ ΠΌΠ΅Π½ΡΠ΅Ρ ΠΌΠ΅ΡΡΠ°ΠΌΠΈ ΠΈΠ³ΡΠΎΠΊΠΎΠ² ΠΎΠ΄Π½ΠΎΠΉ ΡΠΎΠ»ΠΈ ΠΈΠ»ΠΈ ΠΈΡΠΏΠΎΠ»ΡΠ·ΡΠ΅Ρ ΡΠ΅Π·Π΅ΡΠ², Π½Π°Π·Π½Π°ΡΠ°Π΅Ρ ΠΊΠ°ΠΏΠΈΡΠ°Π½ΠΎΠ², ΠΏΠΎΠ΄ΡΠ²Π΅ΡΠΆΠ΄Π°Π΅Ρ ΡΠΎΡΡΠ°Π²Ρ ΠΈ Π·Π°ΠΏΡΡΠΊΠ°Π΅Ρ ΡΠΊΡΠΈΠΌ.',
+ 'How is the first map selected?': 'ΠΠ°ΠΊ Π²ΡΠ±ΠΈΡΠ°Π΅ΡΡΡ ΠΏΠ΅ΡΠ²Π°Ρ ΠΊΠ°ΡΡΠ°?',
+ 'The initial coin toss decides which team bans first. Teams alternate map bans until one map remains. That map becomes the first map of the series.': 'ΠΠ°ΡΠ°Π»ΡΠ½Π°Ρ ΠΆΠ΅ΡΠ΅Π±ΡΡΠ²ΠΊΠ° ΠΎΠΏΡΠ΅Π΄Π΅Π»ΡΠ΅Ρ, ΠΊΠ°ΠΊΠ°Ρ ΠΊΠΎΠΌΠ°Π½Π΄Π° Π±Π°Π½ΠΈΡ ΠΏΠ΅ΡΠ²ΠΎΠΉ. ΠΠΎΠΌΠ°Π½Π΄Ρ ΠΏΠΎ ΠΎΡΠ΅ΡΠ΅Π΄ΠΈ Π±Π°Π½ΡΡ ΠΊΠ°ΡΡΡ, ΠΏΠΎΠΊΠ° Π½Π΅ ΠΎΡΡΠ°Π½Π΅ΡΡΡ ΠΎΠ΄Π½Π° β ΠΎΠ½Π° ΡΡΠ°Π½ΠΎΠ²ΠΈΡΡΡ ΠΏΠ΅ΡΠ²ΠΎΠΉ ΠΊΠ°ΡΡΠΎΠΉ ΡΠ΅ΡΠΈΠΈ.',
+ 'Who picks later maps?': 'ΠΡΠΎ Π²ΡΠ±ΠΈΡΠ°Π΅Ρ ΡΠ»Π΅Π΄ΡΡΡΠΈΠ΅ ΠΊΠ°ΡΡΡ?',
+ 'After a captain or admin records a map result, the team that lost that map immediately chooses the next map from the next ruleset pool. Already played maps are unavailable. After a draw, the initial coin-toss loser receives the pick.': 'ΠΠΎΡΠ»Π΅ ΡΠΈΠΊΡΠ°ΡΠΈΠΈ ΡΠ΅Π·ΡΠ»ΡΡΠ°ΡΠ° ΠΊΠ°ΠΏΠΈΡΠ°Π½ΠΎΠΌ ΠΈΠ»ΠΈ Π°Π΄ΠΌΠΈΠ½ΠΈΡΡΡΠ°ΡΠΎΡΠΎΠΌ ΠΏΡΠΎΠΈΠ³ΡΠ°Π²ΡΠ°Ρ ΠΊΠΎΠΌΠ°Π½Π΄Π° ΡΡΠ°Π·Ρ Π²ΡΠ±ΠΈΡΠ°Π΅Ρ ΡΠ»Π΅Π΄ΡΡΡΡΡ ΠΊΠ°ΡΡΡ ΠΈΠ· ΡΠ»Π΅Π΄ΡΡΡΠ΅Π³ΠΎ ΠΏΡΠ»Π° ΡΠ΅Π³Π»Π°ΠΌΠ΅Π½ΡΠ°. Π‘ΡΠ³ΡΠ°Π½Π½ΡΠ΅ ΠΊΠ°ΡΡΡ Π½Π΅Π΄ΠΎΡΡΡΠΏΠ½Ρ. ΠΠΎΡΠ»Π΅ Π½ΠΈΡΡΠ΅ΠΉ Π²ΡΠ±ΠΎΡ ΠΏΠΎΠ»ΡΡΠ°Π΅Ρ ΠΊΠΎΠΌΠ°Π½Π΄Π°, ΠΏΡΠΎΠΈΠ³ΡΠ°Π²ΡΠ°Ρ Π½Π°ΡΠ°Π»ΡΠ½ΡΠΉ ΠΆΡΠ΅Π±ΠΈΠΉ.',
+ 'How do hero bans work?': 'ΠΠ°ΠΊ ΡΠ°Π±ΠΎΡΠ°ΡΡ Π±Π°Π½Ρ Π³Π΅ΡΠΎΠ΅Π²?',
+ 'Before every map, teams make two bans each in alternating order. The initial coin-toss loser bans first. A teamβs two bans must use different roles, and a team cannot repeat its own hero ban during the same series. It may ban a hero previously banned by the opponent.': 'ΠΠ΅ΡΠ΅Π΄ ΠΊΠ°ΠΆΠ΄ΠΎΠΉ ΠΊΠ°ΡΡΠΎΠΉ ΠΊΠΎΠΌΠ°Π½Π΄Ρ Π΄Π΅Π»Π°ΡΡ ΠΏΠΎ Π΄Π²Π° Π±Π°Π½Π° ΠΏΠΎ ΠΎΡΠ΅ΡΠ΅Π΄ΠΈ. ΠΠ΅ΡΠ²ΠΎΠΉ Π±Π°Π½ΠΈΡ ΠΊΠΎΠΌΠ°Π½Π΄Π°, ΠΏΡΠΎΠΈΠ³ΡΠ°Π²ΡΠ°Ρ Π½Π°ΡΠ°Π»ΡΠ½ΡΠΉ ΠΆΡΠ΅Π±ΠΈΠΉ. ΠΠ²Π° Π±Π°Π½Π° ΠΊΠΎΠΌΠ°Π½Π΄Ρ Π΄ΠΎΠ»ΠΆΠ½Ρ ΠΎΡΠ½ΠΎΡΠΈΡΡΡΡ ΠΊ ΡΠ°Π·Π½ΡΠΌ ΡΠΎΠ»ΡΠΌ; ΠΏΠΎΠ²ΡΠΎΡΡΡΡ ΡΠΎΠ±ΡΡΠ²Π΅Π½Π½ΡΠΉ Π±Π°Π½ Π² ΡΠ΅ΡΠΈΠΈ Π½Π΅Π»ΡΠ·Ρ, Π½ΠΎ ΠΌΠΎΠΆΠ½ΠΎ Π·Π°Π±Π°Π½ΠΈΡΡ Π³Π΅ΡΠΎΡ, ΠΊΠΎΡΠΎΡΠΎΠ³ΠΎ ΡΠ°Π½Π΅Π΅ Π±Π°Π½ΠΈΠ» ΡΠΎΠΏΠ΅ΡΠ½ΠΈΠΊ.',
+ 'How does Bo3 scoring work?': 'ΠΠ°ΠΊ ΡΡΠΈΡΠ°Π΅ΡΡΡ Bo3?',
+ 'The first team to win two maps wins the series. A captain selects the result after each map; admins can correct mistakes. Tournament winners advance automatically.': 'ΠΠ΅ΡΠ²Π°Ρ ΠΊΠΎΠΌΠ°Π½Π΄Π° Ρ Π΄Π²ΡΠΌΡ ΠΏΠΎΠ±Π΅Π΄Π°ΠΌΠΈ Π²ΡΠΈΠ³ΡΡΠ²Π°Π΅Ρ ΡΠ΅ΡΠΈΡ. ΠΠΎΡΠ»Π΅ ΠΊΠ°ΠΆΠ΄ΠΎΠΉ ΠΊΠ°ΡΡΡ ΠΊΠ°ΠΏΠΈΡΠ°Π½ ΡΠΊΠ°Π·ΡΠ²Π°Π΅Ρ ΡΠ΅Π·ΡΠ»ΡΡΠ°Ρ, Π°Π΄ΠΌΠΈΠ½ΠΈΡΡΡΠ°ΡΠΎΡ ΠΌΠΎΠΆΠ΅Ρ ΠΈΡΠΏΡΠ°Π²ΠΈΡΡ ΠΎΡΠΈΠ±ΠΊΡ. ΠΠΎΠ±Π΅Π΄ΠΈΡΠ΅Π»ΠΈ ΡΡΡΠ½ΠΈΡΠ° ΠΏΡΠΎΡ
ΠΎΠ΄ΡΡ Π΄Π°Π»ΡΡΠ΅ Π°Π²ΡΠΎΠΌΠ°ΡΠΈΡΠ΅ΡΠΊΠΈ.',
+ 'What can captains do?': 'Π§ΡΠΎ ΠΌΠΎΠ³ΡΡ Π΄Π΅Π»Π°ΡΡ ΠΊΠ°ΠΏΠΈΡΠ°Π½Ρ?',
+ 'Captains can perform actions for their team: map bans, later-map picks, and hero bans. Admins can perform or correct any authoritative action. Every command is versioned and recorded in the match history.': 'ΠΠ°ΠΏΠΈΡΠ°Π½Ρ ΠΌΠΎΠ³ΡΡ Π΄Π΅ΠΉΡΡΠ²ΠΎΠ²Π°ΡΡ Π·Π° ΡΠ²ΠΎΡ ΠΊΠΎΠΌΠ°Π½Π΄Ρ: Π±Π°Π½ΠΈΡΡ ΠΊΠ°ΡΡΡ, Π²ΡΠ±ΠΈΡΠ°ΡΡ ΡΠ»Π΅Π΄ΡΡΡΠΈΠ΅ ΠΊΠ°ΡΡΡ ΠΈ Π±Π°Π½ΠΈΡΡ Π³Π΅ΡΠΎΠ΅Π². ΠΠ΄ΠΌΠΈΠ½ΠΈΡΡΡΠ°ΡΠΎΡΡ ΠΌΠΎΠ³ΡΡ Π²ΡΠΏΠΎΠ»Π½ΠΈΡΡ ΠΈΠ»ΠΈ ΠΈΡΠΏΡΠ°Π²ΠΈΡΡ Π»ΡΠ±ΠΎΠ΅ Π΄Π΅ΠΉΡΡΠ²ΠΈΠ΅. ΠΡΠ΅ ΠΊΠΎΠΌΠ°Π½Π΄Ρ Π²Π΅ΡΡΠΈΠΎΠ½ΠΈΡΡΡΡΡΡ ΠΈ Π·Π°ΠΏΠΈΡΡΠ²Π°ΡΡΡΡ Π² ΠΈΡΡΠΎΡΠΈΡ ΠΌΠ°ΡΡΠ°.',
}
function translate(value: string) {
@@ -213,8 +269,11 @@ function translate(value: string) {
[/^(.+) wins$/, (m) => `${m[1]} ΠΏΠΎΠ±Π΅ΠΆΠ΄Π°Π΅Ρ`],
[/^Team (.+)$/, (m) => `ΠΠΎΠΌΠ°Π½Π΄Π° ${m[1]}`],
[/^RSVP for (.+)$/, (m) => `Π‘ΡΠ°ΡΡΡ ΡΡΠ°ΡΡΠΈΡ Π΄Π»Ρ ${m[1]}`],
+ [/^Remove (.+) from event$/, (m) => `Π£Π΄Π°Π»ΠΈΡΡ ${m[1]} ΠΈΠ· ΠΌΠΈΠΊΡΠ°`],
+ [/^Open (.+)$/, (m) => `ΠΡΠΊΡΡΡΡ ${m[1]}`],
[/^(\d+) preferred role assignments$/, (m) => `${m[1]} Π½Π°Π·Π½Π°ΡΠ΅Π½ΠΈΠΉ Π½Π° ΠΏΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅ΠΌΡΠ΅ ΡΠΎΠ»ΠΈ`],
[/^(\d+) preferred teammate pairs? kept together$/, (m) => `${m[1]} ΠΏΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅ΠΌΡΡ
ΠΏΠ°Ρ ΡΠΎΡ
ΡΠ°Π½Π΅Π½ΠΎ`],
+ [/^(\d+) avoided teammate choices satisfied$/, (m) => `${m[1]} ΠΏΠΎΠΆΠ΅Π»Π°Π½ΠΈΠΉ ΠΈΠ·Π±Π΅Π³Π°ΡΡ Π½Π°ΠΏΠ°ΡΠ½ΠΈΠΊΠΎΠ² ΡΡΡΠ΅Π½ΠΎ`],
[/^set by (.+)$/, (m) => `ΠΈΠ·ΠΌΠ΅Π½ΠΈΠ» ${m[1]}`],
]
for (const [pattern, formatter] of rules) {
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 2711689..b437b54 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -45,6 +45,7 @@ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible
.avatar, .mini-avatar, .large-avatar { display: grid; place-items: center; border-radius: 50%; background: linear-gradient(135deg,#fa8d36,#703514); color: #fff; font-weight: 800; }
.avatar { width: 36px; height: 36px; font-size: 12px; border: 2px solid #393f48; }
.live-dot { width: 7px; height: 7px; display: inline-block; border-radius: 50%; background: var(--green); box-shadow: 0 0 9px var(--green); }
+.live-dot.reconnecting { background: var(--yellow); box-shadow: 0 0 9px var(--yellow); }
.mobile-menu, .mobile-nav, .bottom-nav { display: none !important; }
main { min-height: calc(100vh - 72px); }
.page { max-width: 1240px; margin: 0 auto; padding: 44px 22px 80px; }
@@ -66,6 +67,7 @@ main { min-height: calc(100vh - 72px); }
.badge-success { color: var(--green); border-color: #23583f; background: #10261c; }
.badge-warning { color: var(--yellow); border-color: #604b20; background: #281f0e; }
.badge-live { color: #ff9160; border-color: #69351f; background: #2a160e; }
+.badge-danger { color: #ff8f8f; border-color: #783737; background: #321516; }
.badge-blue { color: var(--blue); border-color: #22547b; background: #102435; }
.segmented { display: flex; padding: 3px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }
.segmented button { min-height: 34px; padding: 0 14px; border: 0; border-radius: 5px; background: transparent; color: var(--muted); font-size: 12px; font-weight: 700; cursor: pointer; }
@@ -83,8 +85,15 @@ main { min-height: calc(100vh - 72px); }
.section-title h2 { font-size: 17px; }
.section-title p { color: var(--muted); font-size: 12px; margin-top: 4px; }
.event-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
-.event-card { display: grid; grid-template-columns: 95px 1fr; min-height: 210px; overflow: hidden; transition: border-color .2s, transform .2s; }
+.event-card { display: grid; grid-template-columns: 95px 1fr; min-height: 210px; overflow: hidden; color: inherit; text-decoration: none; transition: border-color .2s, transform .2s; }
.event-card:hover { transform: translateY(-2px); border-color: #3d4550; }
+.event-card.status-cancelled { border-color: #673134; background: linear-gradient(145deg,#351719,#171417); }
+.event-card.status-completed { border-color: #28583f; background: linear-gradient(145deg,#123022,#121a17); }
+.event-card.status-live { border-color: #854157; background: linear-gradient(145deg,#421b2a,#1c151a); box-shadow: 0 0 28px rgba(222,67,119,.08); }
+.event-card.status-live .date-block span { color: #ff6f9f; }
+.event-detail.status-cancelled .event-hero,.event-detail.status-cancelled .roster-preview { border-color: #673134; background: linear-gradient(145deg,#351719,#171417); }
+.event-detail.status-completed .event-hero,.event-detail.status-completed .roster-preview { border-color: #28583f; background: linear-gradient(145deg,#123022,#121a17); }
+.event-detail.status-live .event-hero,.event-detail.status-live .roster-preview { border-color: #854157; background: linear-gradient(145deg,#421b2a,#1c151a); }
.date-block { display: flex; flex-direction: column; align-items: center; justify-content: center; border-right: 1px solid var(--border); background: rgba(255,255,255,.018); }
.date-block strong { font-size: 38px; line-height: 1; }
.date-block span { color: var(--orange); font-size: 11px; letter-spacing: .2em; margin-top: 7px; font-weight: 800; }
@@ -94,9 +103,9 @@ main { min-height: calc(100vh - 72px); }
.event-card h3 { font-size: 19px; margin-top: 18px; }
.event-card p { color: var(--muted); font-size: 12px; margin-top: 6px; }
.event-footer { margin-top: auto; padding-top: 18px; border-top: 1px solid var(--border); }
-.event-footer span, .event-footer a { display: flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 750; }
+.event-footer span { display: flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 750; }
.event-footer span { color: var(--muted); }
-.event-footer a { color: var(--orange); }
+.event-footer .event-open { color: var(--orange); }
.detail-layout { display: grid; grid-template-columns: 1.5fr .8fr; gap: 18px; align-items: start; }
.event-hero, .roster-preview { padding: 28px; }
@@ -116,9 +125,9 @@ main { min-height: calc(100vh - 72px); }
.roster-preview .section-title { margin-top: 0; }
.attendance-bar { display: flex; height: 8px; gap: 3px; margin: 28px 0 18px; }
.attendance-bar span { border-radius: 10px; }
-.attendance-bar span:nth-child(1) { width: 72%; background: var(--green); }
-.attendance-bar span:nth-child(2) { width: 18%; background: var(--yellow); }
-.attendance-bar span:nth-child(3) { width: 10%; background: #59616b; }
+.attendance-bar .going { background: var(--green); }
+.attendance-bar .maybe { background: var(--yellow); }
+.attendance-bar .not-going { background: #59616b; }
.attendance-stats { display: grid; gap: 12px; margin-bottom: 28px; }
.attendance-stats span { color: var(--muted); display: flex; align-items: center; gap: 7px; font-size: 12px; }
.attendance-stats strong { margin-left: auto; color: #fff; }
@@ -129,6 +138,12 @@ main { min-height: calc(100vh - 72px); }
.public-roster-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 8px; }
.public-roster-player { min-height: 52px; display: grid; grid-template-columns: 32px minmax(0,1fr) auto; align-items: center; gap: 10px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); }
.public-roster-player strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
+.faq-page { max-width: 1050px; }
+.faq-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 12px; }
+.faq-card { min-height: 160px; padding: 22px; display: grid; grid-template-columns: 38px 1fr; gap: 14px; }
+.faq-card > span { color: var(--orange); font-size: 11px; font-weight: 900; letter-spacing: .12em; }
+.faq-card h2 { font-size: 17px; margin-bottom: 9px; }
+.faq-card p { color: var(--muted); font-size: 12px; line-height: 1.65; }
.profile-banner { padding: 24px; display: flex; align-items: center; gap: 18px; margin-bottom: 16px; }
.large-avatar { width: 70px; height: 70px; font-size: 21px; }
@@ -152,6 +167,8 @@ main { min-height: calc(100vh - 72px); }
.teammate-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; margin-top: 14px; }
.teammate-grid label { min-height: 48px; padding: 8px 10px; display: grid; grid-template-columns: auto 28px 1fr; align-items: center; gap: 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); cursor: pointer; font-size: 10px; }
.teammate-grid label.selected { border-color: var(--blue); background: var(--blue-soft); }.teammate-grid label:has(input:disabled) { opacity: .45; cursor: not-allowed; }
+.teammate-grid label.selected.avoided { border-color: #c85f5f; background: #311719; }
+.avoid-section h2 { color: #ef9696; }
.teammate-grid .mini-avatar { width: 26px; height: 26px; }
.form-footer { padding-top: 20px; border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.form-footer p { color: var(--muted); font-size: 11px; display: flex; align-items: center; gap: 6px; }
@@ -162,6 +179,9 @@ main { min-height: calc(100vh - 72px); }
.admin-grid { display: grid; grid-template-columns: 1fr 300px; gap: 16px; align-items: start; }
.settings-card, .admin-summary, .table-card { padding: 24px; }
.settings-card .section-title, .table-card .section-title { margin-top: 0; }
+.event-admin-actions { display: flex; align-items: center; gap: 7px; }
+.button.danger-outline { min-height: 36px; padding: 0 12px; color: #ff9b9b; border: 1px solid #71383a; background: #281416; }
+.delete-event { color: #ff8f8f; }
.field-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 16px; }
.guest-participant-form { margin-bottom: 24px; padding: 18px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); }
.guest-participant-form > div:first-child { margin-bottom: 14px; }
@@ -180,11 +200,13 @@ textarea { min-height: 85px; resize: vertical; }
.admin-summary li { display: flex; gap: 8px; align-items: center; color: var(--muted); font-size: 12px; }.admin-summary li svg { color: var(--green); }
.count-pills { display: flex; gap: 6px; }
.participant-list { display: grid; }
-.participant-row { min-height: 64px; display: grid; grid-template-columns: 38px minmax(130px,1fr) auto 130px; align-items: center; gap: 12px; border-top: 1px solid var(--border); }
+.participant-row { min-height: 64px; display: grid; grid-template-columns: 38px minmax(130px,1fr) auto 130px 38px; align-items: center; gap: 12px; border-top: 1px solid var(--border); }
.mini-avatar { width: 32px; height: 32px; font-size: 10px; }
.participant-name { display: flex; flex-direction: column; gap: 4px; font-size: 12px; }.participant-name span { color: var(--muted); font-size: 10px; text-transform: capitalize; }.participant-name em { color: var(--yellow); font-style: normal; }
.mini-ratings { display: flex; gap: 5px; }.mini-ratings span { color: var(--muted); background: var(--surface-2); border-radius: 4px; padding: 4px 6px; font-size: 9px; }
.participant-row select { padding: 8px; font-size: 11px; }
+.remove-participant { color: #d98282; }
+.remove-participant:hover { color: #ff9b9b; border-color: #6f3939; background: #281515; }
.balance-layout { display: grid; grid-template-columns: 260px 1fr; gap: 20px; }
.candidate-list .section-title { margin-top: 0; }
@@ -207,6 +229,19 @@ textarea { min-height: 85px; resize: vertical; }
.captain-select { padding: 15px 18px 18px; display: flex; flex-direction: column; gap: 7px; }.captain-select span { display: flex; align-items: center; gap: 6px; }.captain-select select { padding: 9px; font-size: 11px; }
.reserve-bar { margin-top: 12px; padding: 16px 18px; display: flex; align-items: center; justify-content: space-between; gap: 15px; }.reserve-bar > div { display: flex; align-items: center; gap: 9px; }.reserve-bar span { display: flex; flex-direction: column; }.reserve-bar small { color: var(--muted); margin-top: 3px; font-size: 9px; }
.sticky-action { margin-top: 12px; padding: 14px 0; display: flex; align-items: center; justify-content: flex-end; gap: 18px; }.sticky-action p { color: var(--muted); font-size: 10px; }
+.workflow-control { display: grid; gap: 14px; }
+.workflow-progress { padding: 16px; display: grid; grid-template-columns: repeat(7,1fr); gap: 6px; overflow-x: auto; }
+.workflow-progress div { min-width: 105px; display: flex; align-items: center; gap: 7px; color: #555e69; font-size: 9px; text-transform: uppercase; }
+.workflow-progress span { width: 23px; height: 23px; display: grid; place-items: center; border: 1px solid #3a414b; border-radius: 50%; }
+.workflow-progress div.done { color: var(--green); }.workflow-progress div.done span { border-color: #2d7654; background: #10271c; }
+.workflow-action { min-height: 105px; padding: 22px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
+.workflow-action h2 { font-size: 18px; }.workflow-action p { margin-top: 5px; color: var(--muted); font-size: 11px; }
+.roster-editor { padding: 22px; }.roster-editor > .section-title { margin-top: 0; }
+.workflow-team { border: 1px solid var(--border); border-radius: 9px; overflow: hidden; background: #101319; }
+.workflow-team header { padding: 14px; display: flex; align-items: end; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border); }
+.workflow-team header label { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 9px; }.workflow-team header select { padding: 7px; }
+.roster-slot { min-height: 54px; padding: 8px 12px; display: grid; grid-template-columns: 24px minmax(100px,1fr) auto minmax(105px,auto) minmax(105px,auto); align-items: center; gap: 8px; border-bottom: 1px solid #22272e; font-size: 10px; }
+.roster-slot > small { color: var(--muted); }.roster-slot select { max-width: 130px; padding: 7px; font-size: 9px; }
.scoreboard { min-height: 150px; display: grid; grid-template-columns: 1fr 210px 1fr; align-items: center; border: 1px solid var(--border); background: linear-gradient(90deg,rgba(74,37,13,.35),rgba(16,19,24,.9) 42% 58%,rgba(10,45,70,.35)); border-radius: 12px; overflow: hidden; }
.score-team { padding: 25px 30px; }.score-team > span { color: var(--orange); font-size: 9px; text-transform: uppercase; letter-spacing: .18em; font-weight: 900; }.score-team h2 { margin: 5px 0; font-size: clamp(18px,2vw,28px); }.score-team small { color: var(--muted); }.score-team.beta { text-align: right; }.score-team.beta > span { color: var(--blue); }
@@ -261,10 +296,12 @@ textarea { min-height: 85px; resize: vertical; }
.featured-event { padding: 23px; grid-template-columns: 1fr; }.featured-event > * { grid-column: 1; }.featured-score { margin: 10px 0; }.featured-event .button { width: 100%; }.featured-event h2 { font-size: 22px; }
.event-card { grid-template-columns: 70px 1fr; }.date-block strong { font-size: 28px; }.event-card-body { padding: 17px; }
.detail-layout { gap: 10px; }.event-hero,.roster-preview,.public-roster { padding: 20px; }.rsvp-control { grid-template-columns: 1fr; }.rsvp-control button { justify-content: flex-start; padding-left: 18px; }.public-roster-grid { grid-template-columns: 1fr; }
+ .faq-grid { grid-template-columns: 1fr; }.faq-card { min-height: 0; }
.profile-banner { padding: 18px; }.ratings-form { padding: 18px; }.rating-row { grid-template-columns: 1fr; }.rank-select { min-height: 46px; }.form-footer { align-items: stretch; flex-direction: column; }.form-footer .button { width: 100%; }
.teammate-grid { grid-template-columns: repeat(2,1fr); }
- .settings-card,.admin-summary,.table-card { padding: 16px; }.field-grid,.guest-participant-fields { grid-template-columns: 1fr; }.participant-row { grid-template-columns: 34px 1fr 105px; padding: 9px 0; }.mini-ratings { display: none; }
+ .settings-card,.admin-summary,.table-card { padding: 16px; }.field-grid,.guest-participant-fields { grid-template-columns: 1fr; }.participant-row { grid-template-columns: 34px 1fr 105px 36px; padding: 9px 0; }.mini-ratings { display: none; }
.candidate-list { grid-template-columns: 1fr; }.teams-grid { grid-template-columns: 1fr; }.balance-score { grid-template-columns: 1.4fr 1fr 1fr; padding: 15px; gap: 10px; }.reserve-bar,.sticky-action { align-items: stretch; flex-direction: column; }.sticky-action .button { width: 100%; }
+ .workflow-action { align-items: stretch; flex-direction: column; }.workflow-action .button { width: 100%; }.roster-slot { grid-template-columns: 24px 1fr auto; }.roster-slot select { grid-column: 2/-1; max-width: none; width: 100%; }
.scoreboard { min-height: 120px; grid-template-columns: 1fr 90px 1fr; }.score-team { padding: 15px 10px; }.score-team h2 { font-size: 13px; }.score-team small { display: none; }.series-score strong { font-size: 30px; }.series-score .badge { display: none; }
.turn-header { padding: 20px; }.turn-header h2 { font-size: 23px; }.draft-options { grid-template-columns: repeat(2,1fr); padding: 14px; }.turn-footer { align-items: stretch; flex-direction: column; }.turn-footer .button { width: 100%; }
.result-form { grid-template-columns: 1fr; }
diff --git a/memory_bank/active_context.md b/memory_bank/active_context.md
index d19528e..ec6fd60 100644
--- a/memory_bank/active_context.md
+++ b/memory_bank/active_context.md
@@ -2,7 +2,9 @@
## Π’Π΅ΠΊΡΡΠ΅Π΅ ΡΠΎΡΡΠΎΡΠ½ΠΈΠ΅
-Π‘ΠΎΠ±ΡΠ°Π½ ΠΈ ΠΏΡΠΎΠ²Π΅ΡΠ΅Π½ full-stack MVP: Go API Ρ PostgreSQL, Discord OAuth ΠΈ ΡΠ΅ΡΠ²Π΅ΡΠ½ΡΠΌΠΈ ΡΠ΅ΡΡΠΈΡΠΌΠΈ, React-ΠΈΠ½ΡΠ΅ΡΡΠ΅ΠΉΡ, ΠΊΠ°Π»Π΅Π½Π΄Π°ΡΡ ΠΈ RSVP, Π°Π΄ΠΌΠΈΠ½ΠΈΡΡΡΠ°ΡΠΈΠ²Π½ΡΠ΅ ΡΡΠ΅Π½Π°ΡΠΈΠΈ, preference-aware Π±Π°Π»Π°Π½ΡΠΈΡΠΎΠ²ΠΊΠ° 1/2/2, ΠΊΠ°ΠΏΠΈΡΠ°Π½Ρ, Bo3-Π΄ΡΠ°ΡΡΡ ΠΈ ΡΠ΅Π·ΡΠ»ΡΡΠ°ΡΡ, single-elimination ΡΠ΅ΡΠΊΠ°, SSE ΠΈ RU/EN Π»ΠΎΠΊΠ°Π»ΠΈΠ·Π°ΡΠΈΡ. ΠΠΎΠ±Π°Π²Π»Π΅Π½Ρ Docker Compose Π΄Π»Ρ Π»ΠΎΠΊΠ°Π»ΡΠ½ΠΎΠΉ ΡΠ°Π·ΡΠ°Π±ΠΎΡΠΊΠΈ ΠΈ Coolify, ΠΌΠΈΠ³ΡΠ°ΡΠΈΠΈ, OpenAPI-ΠΊΠΎΠ½ΡΡΠ°ΠΊΡ ΠΈ CI.
+Π‘ΠΎΠ±ΡΠ°Π½ ΠΏΠΎΠ»Π½ΡΠΉ Π²Π΅ΡΡΠΈΠΊΠ°Π»ΡΠ½ΡΠΉ production-ΠΏΠ°ΠΉΠΏΠ»Π°ΠΉΠ½ ΡΠΊΡΠΈΠΌΠ°: Π·Π°ΠΊΡΡΡΠΈΠ΅ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΠΈ β versioned Π±Π°Π»Π°Π½ΡΠΈΡΠΎΠ²ΠΊΠ° β ΡΡΡΠ½Π°Ρ ΠΏΡΠ°Π²ΠΊΠ° ΠΈ ΠΏΠΎΠ΄ΡΠ²Π΅ΡΠΆΠ΄Π΅Π½ΠΈΠ΅ ΡΠΎΡΡΠ°Π²ΠΎΠ² 1/2/2 β ΠΊΠ°ΠΏΠΈΡΠ°Π½Ρ β Π°ΡΠΎΠΌΠ°ΡΠ½ΡΠΉ ΡΡΠ°ΡΡ Bo3 ΠΈΠ»ΠΈ ΡΡΡΠ½ΠΈΡΠ° β ΠΆΠ΅ΡΠ΅Π±ΡΡΠ²ΠΊΠ° β Π±Π°Π½Ρ ΠΊΠ°ΡΡ β Π±Π°Π½Ρ Π³Π΅ΡΠΎΠ΅Π² β ΡΠ΅Π·ΡΠ»ΡΡΠ°ΡΡ ΠΈ Π·Π°Π²Π΅ΡΡΠ΅Π½ΠΈΠ΅. Π‘ΠΎΡΡΠΎΡΠ½ΠΈΠ΅ ΡΠΎΠ±ΡΡΠΈΡ, roster draft ΠΈ ΡΠ΅ΡΠΈΠΈ ΡΠΎΡ
ΡΠ°Π½ΡΠ΅ΡΡΡ Π² PostgreSQL Ρ optimistic locking, Π°ΡΠ΄ΠΈΡΠΎΠΌ ΠΈ Π³Π»ΠΎΠ±Π°Π»ΡΠ½ΠΎΠΉ SSE-ΡΠΈΠ½Ρ
ΡΠΎΠ½ΠΈΠ·Π°ΡΠΈΠ΅ΠΉ Π±Π΅Π· ΡΡΡΠ½ΠΎΠ³ΠΎ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΡ ΡΡΡΠ°Π½ΠΈΡΡ.
+
+Admin UI ΡΠΎΠ΄Π΅ΡΠΆΠΈΡ ΠΏΠΎΡΠ°Π³ΠΎΠ²ΡΠΉ workflow ΠΈ roster editor Ρ same-role swap, ΡΠ΅Π·Π΅ΡΠ²ΠΎΠΌ ΠΈ Π·Π°ΠΏΡΡΠΊΠΎΠΌ. Live, spectator ΠΈ bracket ΠΈΡΠΏΠΎΠ»ΡΠ·ΡΡΡ ΡΠ΅Π°Π»ΡΠ½ΡΠ΅ API-ΠΊΠΎΠΌΠ°Π½Π΄Ρ ΠΈ realtime refresh; demo fixtures ΠΎΡΡΠ°Π»ΠΈΡΡ ΡΠΎΠ»ΡΠΊΠΎ Π»ΠΎΠΊΠ°Π»ΡΠ½ΡΠΌ showcase.
## ΠΠΎΠ΄ΡΠ²Π΅ΡΠΆΠ΄ΡΠ½Π½ΡΠ΅ ΡΡΠ΅Π±ΠΎΠ²Π°Π½ΠΈΡ
@@ -10,10 +12,11 @@
- backend Π½Π° Go Ρ Clean Architecture;
- ΡΠΎΠ²ΡΠ΅ΠΌΠ΅Π½Π½ΡΠΉ frontend Π½Π° ΡΡΠ΅ΠΉΠΌΠ²ΠΎΡΠΊΠ΅;
- Discord OAuth ΠΈ ΡΠ°ΠΌΠΎΡΡΠΎΡΡΠ΅Π»ΡΠ½ΠΎΠ΅ Π·Π°ΠΏΠΎΠ»Π½Π΅Π½ΠΈΠ΅ ΠΈΠ³ΡΠΎΠΊΠΎΠΌ ΡΠ΅ΠΉΡΠΈΠ½Π³ΠΎΠ² Tank, Damage ΠΈ Support;
-- Π²ΡΠ±ΠΎΡ ΠΈΠ³ΡΠΎΠΊΠΎΠΌ ΠΏΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅ΠΌΡΡ
ΡΠΎΠ»Π΅ΠΉ ΠΈ Π΄ΠΎ ΡΡΡΡ
ΠΆΠ΅Π»Π°Π΅ΠΌΡΡ
ΡΠΎΡΠ·Π½ΠΈΠΊΠΎΠ²;
+- Π²ΡΠ±ΠΎΡ ΠΈΠ³ΡΠΎΠΊΠΎΠΌ ΠΏΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅ΠΌΡΡ
ΡΠΎΠ»Π΅ΠΉ, Π΄ΠΎ ΡΡΡΡ
ΠΆΠ΅Π»Π°Π΅ΠΌΡΡ
ΡΠΎΡΠ·Π½ΠΈΠΊΠΎΠ² ΠΈ Π΄ΠΎ ΡΡΡΡ
avoid-ΠΈΠ³ΡΠΎΠΊΠΎΠ²;
- ΠΏΠ΅ΡΠ΅ΠΊΠ»ΡΡΠ΅Π½ΠΈΠ΅ Π²ΡΠ΅Π³ΠΎ ΠΈΠ½ΡΠ΅ΡΡΠ΅ΠΉΡΠ° ΠΌΠ΅ΠΆΠ΄Ρ RU ΠΈ EN;
- ΠΊΠ°Π»Π΅Π½Π΄Π°ΡΡ Π·Π°ΠΏΠ»Π°Π½ΠΈΡΠΎΠ²Π°Π½Π½ΡΡ
ΠΌΠΈΠΊΡΠΎΠ² ΡΠΎ ΡΡΠ°ΡΡΡΠ°ΠΌΠΈ Going / Maybe / NotGoing;
- Π°Π΄ΠΌΠΈΠ½ΠΈΡΡΡΠ°ΡΠΈΠ²Π½Π°Ρ ΠΏΠ°Π½Π΅Π»Ρ ΡΠΏΡΠ°Π²Π»Π΅Π½ΠΈΡ ΡΠΎΠ±ΡΡΠΈΡΠΌΠΈ, ΠΈΠ³ΡΠΎΠΊΠ°ΠΌΠΈ ΠΈ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΡΠΌΠΈ;
+- Admin ΠΌΠΎΠΆΠ΅Ρ ΠΎΡΠΌΠ΅Π½ΠΈΡΡ ΡΠΎΠ±ΡΡΠΈΠ΅ Ρ ΡΠΎΡ
ΡΠ°Π½Π΅Π½ΠΈΠ΅ΠΌ Π΅Π³ΠΎ Π² ΠΊΠ°Π»Π΅Π½Π΄Π°ΡΠ΅ ΠΈΠ»ΠΈ ΠΏΠΎΠ»Π½ΠΎΡΡΡΡ ΡΠ΄Π°Π»ΠΈΡΡ ΡΠΎΠ±ΡΡΠΈΠ΅ ΠΈ ΡΠ²ΡΠ·Π°Π½Π½ΡΠ΅ Π΄Π°Π½Π½ΡΠ΅;
- Π²ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎΡΡΡ Admin ΠΎΡΠΌΠ΅ΡΠΈΡΡ ΡΡΠ°ΡΡΠΈΠ΅ Π·Π° Π΄ΡΡΠ³ΠΎΠ³ΠΎ ΠΈΠ³ΡΠΎΠΊΠ°;
- Π°Π²ΡΠΎΠΌΠ°ΡΠΈΡΠ΅ΡΠΊΠ°Ρ Π±Π°Π»Π°Π½ΡΠΈΡΠΎΠ²ΠΊΠ° ΠΏΠΎΠ»Π½ΡΡ
ΠΊΠΎΠΌΠ°Π½Π΄ 1 Tank / 2 Damage / 2 Support;
- ΡΠ΅Π·Π΅ΡΠ² Π΄Π»Ρ ΡΡΠ°ΡΡΠ½ΠΈΠΊΠΎΠ², Π½Π΅ Π²ΠΎΡΠ΅Π΄ΡΠΈΡ
Π² ΠΏΠΎΠ»Π½ΡΠ΅ ΠΊΠΎΠΌΠ°Π½Π΄Ρ;
@@ -26,13 +29,15 @@
- production-ΡΠ°Π·Π²ΡΡΡΡΠ²Π°Π½ΠΈΠ΅ Π΅Π΄ΠΈΠ½ΡΠΌ Docker Compose resource Π² Coolify;
- PostgreSQL Π² ΡΠΎΠΌ ΠΆΠ΅ Compose-ΡΡΠ΅ΠΊΠ΅ Ρ persistent volume;
- ΠΌΠΎΠ±ΠΈΠ»ΡΠ½ΡΠΉ ΠΈ Π΄Π΅ΡΠΊΡΠΎΠΏΠ½ΡΠΉ ΠΈΠ½ΡΠ΅ΡΡΠ΅ΠΉΡ.
+- ΠΊΠ°ΡΡΠΎΡΠΊΠΈ ΠΎΡΠΌΠ΅Π½ΡΠ½Π½ΡΡ
ΡΠΎΠ±ΡΡΠΈΠΉ ΠΎΡΠΎΠ±ΡΠ°ΠΆΠ°ΡΡΡΡ ΠΊΡΠ°ΡΠ½ΡΠΌΠΈ, Π·Π°Π²Π΅ΡΡΡΠ½Π½ΡΡ
β Π·Π΅Π»ΡΠ½ΡΠΌΠΈ, Π°ΠΊΡΠΈΠ²Π½ΡΡ
β ΡΠΎΠ·ΠΎΠ²ΡΠΌΠΈ Ρ ΠΌΠ΅ΡΠΊΠΎΠΉ LIVE.
## ΠΡΡ
ΠΎΠ΄Π½ΡΠΉ ΡΠ΅Π³Π»Π°ΠΌΠ΅Π½Ρ
- ΠΎΡΠ½ΠΎΠ²Π½ΠΎΠΉ Bo3: Control β Hybrid/Escort β Control;
- Π°Π»ΡΡΠ΅ΡΠ½Π°ΡΠΈΠ²Π½ΡΠΉ Bo3: Control β Push β Escort;
-- ΠΏΠ΅ΡΠ²ΡΠΉ Π±Π°Π½ ΠΊΠ°ΡΡ ΠΎΠΏΡΠ΅Π΄Π΅Π»ΡΠ΅ΡΡΡ ΠΆΠ΅ΡΠ΅Π±ΡΡΠ²ΠΊΠΎΠΉ;
-- ΠΊΠ°ΡΡΡ Π±Π°Π½ΡΡΡΡ ΠΏΠΎ ΠΎΡΠ΅ΡΠ΅Π΄ΠΈ Π΄ΠΎ ΠΎΠ΄Π½ΠΎΠΉ ΠΎΡΡΠ°Π²ΡΠ΅ΠΉΡΡ;
+- ΠΏΠ΅ΡΠ²Π°Ρ ΠΊΠ°ΡΡΠ° ΠΎΠΏΡΠ΅Π΄Π΅Π»ΡΠ΅ΡΡΡ Π±Π°Π½Π°ΠΌΠΈ: ΠΏΠ΅ΡΠ²ΡΠΉ Π±Π°Π½ Π·Π°Π΄Π°ΡΡ ΠΆΠ΅ΡΠ΅Π±ΡΡΠ²ΠΊΠ°, Π·Π°ΡΠ΅ΠΌ ΠΊΠΎΠΌΠ°Π½Π΄Ρ Π±Π°Π½ΡΡ ΠΏΠΎ ΠΎΡΠ΅ΡΠ΅Π΄ΠΈ Π΄ΠΎ ΠΎΠ΄Π½ΠΎΠΉ ΠΎΡΡΠ°Π²ΡΠ΅ΠΉΡΡ;
+- Π²ΡΠΎΡΡΡ ΠΈ ΠΏΠΎΡΠ»Π΅Π΄ΡΡΡΠΈΠ΅ ΠΊΠ°ΡΡΡ Π½Π°ΠΏΡΡΠΌΡΡ Π²ΡΠ±ΠΈΡΠ°Π΅Ρ ΠΊΠΎΠΌΠ°Π½Π΄Π°, ΠΏΡΠΎΠΈΠ³ΡΠ°Π²ΡΠ°Ρ ΠΏΡΠ΅Π΄ΡΠ΄ΡΡΡΡ ΠΊΠ°ΡΡΡ;
+- ΠΏΠΎΡΠ»Π΅ Π½ΠΈΡΡΠ΅ΠΉ ΡΠ»Π΅Π΄ΡΡΡΡΡ ΠΊΠ°ΡΡΡ Π²ΡΠ±ΠΈΡΠ°Π΅Ρ ΠΊΠΎΠΌΠ°Π½Π΄Π°, ΠΏΡΠΎΠΈΠ³ΡΠ°Π²ΡΠ°Ρ Π½Π°ΡΠ°Π»ΡΠ½ΡΠΉ ΠΆΡΠ΅Π±ΠΈΠΉ;
- ΡΠ°ΠΉΠ±ΡΠ΅ΠΉΠΊΠ΅Ρ Control Π½Π΅ ΠΏΠΎΠ²ΡΠΎΡΡΠ΅Ρ ΡΠΆΠ΅ ΡΡΠ³ΡΠ°Π½Π½ΡΡ Control-ΠΊΠ°ΡΡΡ;
- ΠΏΠ΅ΡΠ΅Π΄ ΠΊΠ°ΠΆΠ΄ΠΎΠΉ ΠΊΠ°ΡΡΠΎΠΉ ΠΊΠΎΠΌΠ°Π½Π΄Ρ Π΄Π΅Π»Π°ΡΡ ΠΏΠΎ Π΄Π²Π° Π±Π°Π½Π° Π³Π΅ΡΠΎΠ΅Π² Π² ΠΏΠΎΡΡΠ΄ΠΊΠ΅ A β B β A β B;
- Π΄Π²Π° Π±Π°Π½Π° ΠΎΠ΄Π½ΠΎΠΉ ΠΊΠΎΠΌΠ°Π½Π΄Ρ Π΄ΠΎΠ»ΠΆΠ½Ρ ΠΎΡΠ½ΠΎΡΠΈΡΡΡΡ ΠΊ ΡΠ°Π·Π½ΡΠΌ ΡΠΎΠ»ΡΠΌ;
@@ -53,12 +58,17 @@
- ΠΈΠ³ΡΠΎΠΊ Π²Ρ
ΠΎΠ΄ΠΈΡ ΡΠ΅ΡΠ΅Π· Discord;
- ΠΈΠ³ΡΠΎΠΊ ΡΠ°ΠΌΠΎΡΡΠΎΡΡΠ΅Π»ΡΠ½ΠΎ ΡΠΊΠ°Π·ΡΠ²Π°Π΅Ρ Overwatch-ΡΠ°Π½Π³ ΠΎΡ Bronze 5 Π΄ΠΎ Champion 1 Π΄Π»Ρ ΠΊΠ°ΠΆΠ΄ΠΎΠΉ ΡΠΎΠ»ΠΈ;
- Π±Π°Π»Π°Π½ΡΠΈΡΠΎΠ²ΡΠΈΠΊ Π°Π²ΡΠΎΠΌΠ°ΡΠΈΡΠ΅ΡΠΊΠΈ Π½Π°Π·Π½Π°ΡΠ°Π΅Ρ ΡΠΎΠ»ΠΈ ΠΈ ΡΠΎΠ±ΠΈΡΠ°Π΅Ρ ΠΊΠΎΠΌΠ°Π½Π΄Ρ 1/2/2;
-- ΠΏΡΠ΅Π΄ΠΏΠΎΡΡΠ΅Π½ΠΈΡ ΡΠΎΠ»Π΅ΠΉ ΠΈ ΡΠΎΡΠ·Π½ΠΈΠΊΠΎΠ² ΡΡΠΈΡΡΠ²Π°ΡΡΡΡ ΠΊΠ°ΠΊ ΠΌΡΠ³ΠΊΠΈΠ΅ ΡΡΡΠ°ΡΡ ΠΏΠΎΡΠ»Π΅ ΡΡΠ΅Π±ΠΎΠ²Π°Π½ΠΈΠΉ Π±Π°Π»Π°Π½ΡΠ°;
+- ΠΏΡΠ΅Π΄ΠΏΠΎΡΡΠ΅Π½ΠΈΡ ΡΠΎΠ»Π΅ΠΉ, ΡΠΎΡΠ·Π½ΠΈΠΊΠΎΠ² ΠΈ avoid-ΡΠΏΠΈΡΠΊΠΈ ΡΡΠΈΡΡΠ²Π°ΡΡΡΡ ΠΊΠ°ΠΊ ΠΌΡΠ³ΠΊΠΈΠ΅ ΡΡΡΠ°ΡΡ ΠΏΠΎΡΠ»Π΅ ΡΡΠ΅Π±ΠΎΠ²Π°Π½ΠΈΠΉ Π±Π°Π»Π°Π½ΡΠ°;
- ΡΡΠ°ΡΡΠ½ΠΈΠΊΠΈ ΡΠ²Π΅ΡΡ
ΠΏΠΎΠ»Π½ΠΎΠ³ΠΎ ΡΠΎΡΡΠ°Π²Π° ΠΎΡΡΠ°ΡΡΡΡ Π² ΡΠ΅Π·Π΅ΡΠ²Π΅;
- ΠΏΠ΅ΡΠ²Π°Ρ ΡΡΡΠ½ΠΈΡΠ½Π°Ρ ΡΠ΅ΡΠΊΠ° β single elimination;
- ΠΈΠ³ΡΠΎΠΊ ΠΎΡΠΌΠ΅ΡΠ°Π΅ΡΡΡ Π½Π° ΠΌΠΈΠΊΡ ΡΠ΅ΡΠ΅Π· ΠΊΠ°Π»Π΅Π½Π΄Π°ΡΡ;
- Admin ΠΌΠΎΠΆΠ΅Ρ ΠΈΠ·ΠΌΠ΅Π½ΠΈΡΡ RSVP Π·Π° ΠΈΠ³ΡΠΎΠΊΠ°, Π° Π΄Π΅ΠΉΡΡΠ²ΠΈΠ΅ ΡΠΎΡ
ΡΠ°Π½ΡΠ΅Ρ Π°Π²ΡΠΎΡΠ°;
- ΠΊΠ°ΠΏΠΈΡΠ°Π½ Π½Π°Π·Π½Π°ΡΠ°Π΅ΡΡΡ ΠΎΡΠ΄Π΅Π»ΡΠ½ΠΎ Π΄Π»Ρ ΠΊΠ°ΠΆΠ΄ΠΎΠΉ ΠΊΠΎΠΌΠ°Π½Π΄Ρ ΠΈ Π΄ΠΎΠ»ΠΆΠ΅Π½ Π²Ρ
ΠΎΠ΄ΠΈΡΡ Π² Π΅Ρ ΡΠΎΡΡΠ°Π²;
+- Admin Π·Π°ΠΊΡΡΠ²Π°Π΅Ρ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΡ ΠΈ ΡΠΈΠΊΡΠΈΡΡΠ΅Ρ ΡΠΏΠΈΡΠΎΠΊ ΡΡΠ°ΡΡΠ½ΠΈΠΊΠΎΠ² ΠΏΠ΅ΡΠ΅Π΄ Π±Π°Π»Π°Π½ΡΠΈΡΠΎΠ²ΠΊΠΎΠΉ;
+- Π΄ΠΎ ΠΏΠΎΠ΄ΡΠ²Π΅ΡΠΆΠ΄Π΅Π½ΠΈΡ ΡΠΎΡΡΠ°Π²ΠΎΠ² Admin ΠΌΠΎΠΆΠ΅Ρ ΠΌΠ΅Π½ΡΡΡ ΠΈΠ³ΡΠΎΠΊΠΎΠ² ΠΎΠ΄Π½ΠΎΠΉ ΡΠΎΠ»ΠΈ ΠΌΠ΅ΡΡΠ°ΠΌΠΈ ΠΈ Π·Π°ΠΌΠ΅Π½ΡΡΡ ΠΈΠ³ΡΠΎΠΊΠ° ΡΠ΅Π·Π΅ΡΠ²Π½ΡΠΌ;
+- ΠΏΠΎΠ΄ΡΠ²Π΅ΡΠΆΠ΄ΡΠ½Π½ΡΠ΅ ΡΠΎΡΡΠ°Π²Ρ ΠΈ ΠΊΠ°ΠΏΠΈΡΠ°Π½Ρ ΡΠ²Π»ΡΡΡΡΡ ΠΎΠ±ΡΠ·Π°ΡΠ΅Π»ΡΠ½ΡΠΌ ΡΡΠ»ΠΎΠ²ΠΈΠ΅ΠΌ Π·Π°ΠΏΡΡΠΊΠ° ΡΠΊΡΠΈΠΌΠ°;
+- Π·Π°ΠΏΡΡΠΊ ΡΠΊΡΠΈΠΌΠ° ΡΠΎΠ·Π΄Π°ΡΡ ΡΠ΅ΡΠ²Π΅ΡΠ½ΡΡ ΡΠ΅ΡΠΈΡ ΠΈ ΠΏΠ΅ΡΠ΅Π²ΠΎΠ΄ΠΈΡ ΡΠΎΠ±ΡΡΠΈΠ΅ Π² ΠΏΠΎΡΠ»Π΅Π΄ΠΎΠ²Π°ΡΠ΅Π»ΡΠ½ΡΠΉ ΠΊΠΎΠ½Π΅ΡΠ½ΡΠΉ Π°Π²ΡΠΎΠΌΠ°Ρ ΠΆΠ΅ΡΠ΅Π±ΡΡΠ²ΠΊΠΈ, Π±Π°Π½ΠΎΠ² ΠΈ ΡΠ΅Π·ΡΠ»ΡΡΠ°ΡΠΎΠ²;
+- ΠΏΠΎΡΠ»Π΅ Π·Π°ΠΏΡΡΠΊΠ° ΠΎΠ±ΡΡΠ½ΠΎΠ΅ ΡΠ΅Π΄Π°ΠΊΡΠΈΡΠΎΠ²Π°Π½ΠΈΠ΅ ΡΠΎΡΡΠ°Π²ΠΎΠ² Π±Π»ΠΎΠΊΠΈΡΡΠ΅ΡΡΡ; Π°Π²Π°ΡΠΈΠΉΠ½Π°Ρ Π·Π°ΠΌΠ΅Π½Π° Π²ΡΠΏΠΎΠ»Π½ΡΠ΅ΡΡΡ Admin ΠΎΡΠ΄Π΅Π»ΡΠ½ΠΎ ΠΈ ΡΠΈΠΊΡΠΈΡΡΠ΅ΡΡΡ Π² Π°ΡΠ΄ΠΈΡΠ΅;
- ΠΈΡΡ
ΠΎΠ΄ ΠΊΠ°ΡΡΡ Π·Π°Π΄Π°ΡΡΡΡ ΠΊΠ°ΠΊ ΠΏΠΎΠ±Π΅Π΄Π° ΠΎΠ΄Π½ΠΎΠΉ ΠΈΠ· ΠΊΠΎΠΌΠ°Π½Π΄ ΠΈΠ»ΠΈ Π½ΠΈΡΡΡ;
- ΡΠ΅ΡΠ²Π΅Ρ Π°Π²ΡΠΎΠΌΠ°ΡΠΈΡΠ΅ΡΠΊΠΈ ΠΏΠ΅ΡΠ΅ΡΡΠΈΡΡΠ²Π°Π΅Ρ ΡΡΡΡ Bo3 ΠΈ ΠΏΠΎΠ±Π΅Π΄ΠΈΡΠ΅Π»Ρ ΡΠ΅ΡΠΈΠΈ;
- frontend, API ΠΈ PostgreSQL ΡΠ°Π·Π²ΠΎΡΠ°ΡΠΈΠ²Π°ΡΡΡΡ ΠΎΠ΄Π½ΠΈΠΌ Compose-ΡΡΠ΅ΠΊΠΎΠΌ;
@@ -66,6 +76,6 @@
## ΠΠ»ΠΈΠΆΠ°ΠΉΡΠΈΠΉ ΡΠ»Π΅Π΄ΡΡΡΠΈΠΉ ΡΠ°Π³
-Π Π°Π·Π²Π΅ΡΠ½ΡΡΡ ΡΠ΅ΡΡΠΎΠ²ΡΠΉ ΡΠΊΠ·Π΅ΠΌΠΏΠ»ΡΡ ΡΠ΅ΡΠ΅Π· Coolify, Π½Π°ΡΡΡΠΎΠΈΡΡ Discord OAuth ΠΈ Π²ΡΠΏΠΎΠ»Π½ΠΈΡΡ ΠΏΡΠΈΡΠΌΠΎΡΠ½ΠΎΠ΅ ΡΠ΅ΡΡΠΈΡΠΎΠ²Π°Π½ΠΈΠ΅ ΠΏΠΎΠ»Π½ΠΎΠ³ΠΎ ΡΡΠ΅Π½Π°ΡΠΈΡ Π½Π° ΡΠ΅Π°Π»ΡΠ½ΡΡ
Π°ΠΊΠΊΠ°ΡΠ½ΡΠ°Ρ
ΠΈ PostgreSQL.
+ΠΡΠΎΠ²Π΅ΡΠΈΡΡ ΠΌΠΈΠ³ΡΠ°ΡΠΈΡ `006_event_workflow.sql` ΠΈ ΠΏΠΎΠ»Π½ΡΠΉ ΡΡΠ΅Π½Π°ΡΠΈΠΉ Π½Π° staging Ρ ΡΠ΅Π°Π»ΡΠ½ΠΎΠΉ PostgreSQL (`TEST_DATABASE_URL`), Π·Π°ΡΠ΅ΠΌ ΠΏΡΠΎΠ²Π΅ΡΡΠΈ ΠΏΠ΅ΡΠ²ΡΠΉ ΠΏΡΠΎΠ±Π½ΡΠΉ ΠΌΠΈΠΊΡ ΡΠ΅ΡΠ΅Π· production UI.
-ΠΡΠΊΡΡΡΠΎΠ΅ ΠΏΡΠΎΠ΄ΡΠΊΡΠΎΠ²ΠΎΠ΅ ΡΠ΅ΡΠ΅Π½ΠΈΠ΅, ΠΊΠΎΡΠΎΡΠΎΠ΅ Π½Π΅ Π±Π»ΠΎΠΊΠΈΡΡΠ΅Ρ ΠΊΠ°ΡΠΊΠ°Ρ: ΠΊΠ°ΠΊ Π²ΡΠ±ΠΈΡΠ°Π΅ΡΡΡ Hybrid/Escort Π΄Π»Ρ Π²ΡΠΎΡΠΎΠΉ ΠΊΠ°ΡΡΡ β Π·Π°ΡΠ°Π½Π΅Π΅ ΠΎΡΠ³Π°Π½ΠΈΠ·Π°ΡΠΎΡΠΎΠΌ, ΡΠ»ΡΡΠ°ΠΉΠ½ΠΎ ΠΈΠ»ΠΈ ΠΎΡΠ΄Π΅Π»ΡΠ½ΡΠΌ Π΄Π΅ΠΉΡΡΠ²ΠΈΠ΅ΠΌ ΠΊΠΎΠΌΠ°Π½Π΄.
+ΠΡΠ±Π»ΠΈΡΠ½Π°Ρ Π²ΠΊΠ»Π°Π΄ΠΊΠ° FAQ ΠΎΠΏΠΈΡΡΠ²Π°Π΅Ρ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΡ, Π±Π°Π»Π°Π½ΡΠΈΡΠΎΠ²ΠΊΡ, ΠΏΠΎΠ»Π½ΠΎΠΌΠΎΡΠΈΡ ΠΊΠ°ΠΏΠΈΡΠ°Π½ΠΎΠ², Π²ΡΠ±ΠΎΡ ΠΊΠ°ΡΡ, Π±Π°Π½Ρ Π³Π΅ΡΠΎΠ΅Π² ΠΈ ΠΏΠΎΠ΄ΡΡΡΡ Bo3.
diff --git a/memory_bank/architecture.md b/memory_bank/architecture.md
index 278385d..80324a2 100644
--- a/memory_bank/architecture.md
+++ b/memory_bank/architecture.md
@@ -64,7 +64,7 @@ frontend/src/
## ΠΡΠ°Π½ΠΈΡΡ ΠΌΠΎΠ΄ΡΠ»Π΅ΠΉ
-- Players: ΠΏΡΠΎΡΠΈΠ»ΠΈ, ΡΠ΅ΠΉΡΠΈΠ½Π³ΠΈ, ΠΏΡΠ΅Π΄ΠΏΠΎΡΡΠ΅Π½ΠΈΡ ΡΠΎΠ»Π΅ΠΉ ΠΈ ΠΆΠ΅Π»Π°Π΅ΠΌΡΡ
ΡΠΎΡΠ·Π½ΠΈΠΊΠΎΠ²;
+- Players: ΠΏΡΠΎΡΠΈΠ»ΠΈ, ΡΠ΅ΠΉΡΠΈΠ½Π³ΠΈ, ΠΏΡΠ΅Π΄ΠΏΠΎΡΡΠ΅Π½ΠΈΡ ΡΠΎΠ»Π΅ΠΉ, ΠΆΠ΅Π»Π°Π΅ΠΌΡΡ
ΡΠΎΡΠ·Π½ΠΈΠΊΠΎΠ² ΠΈ avoid-ΡΠΏΠΈΡΠΊΠΈ;
- Auth: Discord OAuth, ΡΠ΅ΡΡΠΈΠΈ ΠΈ ΡΠΎΠ»ΠΈ Π΄ΠΎΡΡΡΠΏΠ°;
- Admin: ΡΠΏΡΠ°Π²Π»Π΅Π½ΠΈΠ΅ ΠΈΠ³ΡΠΎΠΊΠ°ΠΌΠΈ, ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΡΠΌΠΈ, ΡΠΎΠ±ΡΡΠΈΡΠΌΠΈ ΠΈ ΠΊΠ°ΠΏΠΈΡΠ°Π½Π°ΠΌΠΈ;
- Events: ΠΊΠ°Π»Π΅Π½Π΄Π°ΡΡ, ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΠΈ ΡΡΠ°ΡΡΠ½ΠΈΠΊΠΎΠ² ΠΈ ΡΠΎΡΡΠΎΡΠ½ΠΈΠ΅ ΠΈΠ³ΡΠΎΠ²ΠΎΠ³ΠΎ Π²Π΅ΡΠ΅ΡΠ°;
@@ -78,13 +78,21 @@ frontend/src/
### ΠΠ°Π»Π°Π½ΡΠΈΡΠΎΠ²ΡΠΈΠΊ
-Π MVP β Π΄Π΅ΡΠ΅ΡΠΌΠΈΠ½ΠΈΡΠΎΠ²Π°Π½Π½ΡΠΉ ΠΎΠΏΡΠΈΠΌΠΈΠ·Π°ΡΠΎΡ Ρ Π²Π΅ΡΠΎΠ²ΠΎΠΉ ΡΡΠ½ΠΊΡΠΈΠ΅ΠΉ ΠΊΠ°ΡΠ΅ΡΡΠ²Π° ΠΈ ΠΎΠ³ΡΠ°Π½ΠΈΡΠ΅Π½Π½ΡΠΌ ΠΏΠΎΠΈΡΠΊΠΎΠΌ Π²Π°ΡΠΈΠ°Π½ΡΠΎΠ². Overwatch-ΡΠ°Π½Π³ΠΈ Bronze 5 β Champion 1 ΠΎΡΠΎΠ±ΡΠ°ΠΆΠ°ΡΡΡΡ Π² ordinal 1β40 ΡΠΎΠ»ΡΠΊΠΎ Π΄Π»Ρ Π²Π½ΡΡΡΠ΅Π½Π½ΠΈΡ
Π²ΡΡΠΈΡΠ»Π΅Π½ΠΈΠΉ. ΠΡΡΡΠΊΠΈΠ΅ ΠΎΠ³ΡΠ°Π½ΠΈΡΠ΅Π½ΠΈΡ ΠΎΠ±Π΅ΡΠΏΠ΅ΡΠΈΠ²Π°ΡΡ ΡΠΎΡΡΠ°Π² 1/2/2 ΠΈ ΡΠ½ΠΈΠΊΠ°Π»ΡΠ½ΠΎΡΡΡ ΠΈΠ³ΡΠΎΠΊΠΎΠ². ΠΡΠ³ΠΊΠΈΠ΅ ΡΡΡΠ°ΡΡ ΡΡΠΈΡΡΠ²Π°ΡΡ ΠΎΠ±ΡΠΈΠΉ ΠΈ ΡΠΎΠ»Π΅Π²ΠΎΠΉ ΡΠ°Π·Π±ΡΠΎΡ ΡΠ΅ΠΉΡΠΈΠ½Π³Π°, Π½Π°Π·Π½Π°ΡΠ΅Π½ΠΈΠ΅ Π²Π½Π΅ ΠΏΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅ΠΌΠΎΠΉ ΡΠΎΠ»ΠΈ ΠΈ ΡΠ°Π·Π΄Π΅Π»Π΅Π½ΠΈΠ΅ ΠΆΠ΅Π»Π°Π΅ΠΌΡΡ
ΡΠΎΡΠ·Π½ΠΈΠΊΠΎΠ². ΠΠΎΡΠ»Π΅ ΠΏΠΎΡΡΡΠΎΠ΅Π½ΠΈΡ Π½Π΅ΡΠΊΠΎΠ»ΡΠΊΠΈΡ
Π½Π°ΡΠ°Π»ΡΠ½ΡΡ
Π²Π°ΡΠΈΠ°Π½ΡΠΎΠ² Π²ΡΠΏΠΎΠ»Π½ΡΠ΅ΡΡΡ Π»ΠΎΠΊΠ°Π»ΡΠ½ΡΠΉ ΠΏΠΎΠΈΡΠΊ ΠΎΠ±ΠΌΠ΅Π½Π°ΠΌΠΈ ΠΈΠ³ΡΠΎΠΊΠΎΠ² ΠΎΠ΄Π½ΠΎΠΉ ΡΠΎΠ»ΠΈ.
+Π MVP β Π΄Π΅ΡΠ΅ΡΠΌΠΈΠ½ΠΈΡΠΎΠ²Π°Π½Π½ΡΠΉ ΠΎΠΏΡΠΈΠΌΠΈΠ·Π°ΡΠΎΡ Ρ Π²Π΅ΡΠΎΠ²ΠΎΠΉ ΡΡΠ½ΠΊΡΠΈΠ΅ΠΉ ΠΊΠ°ΡΠ΅ΡΡΠ²Π° ΠΈ ΠΎΠ³ΡΠ°Π½ΠΈΡΠ΅Π½Π½ΡΠΌ ΠΏΠΎΠΈΡΠΊΠΎΠΌ Π²Π°ΡΠΈΠ°Π½ΡΠΎΠ². Overwatch-ΡΠ°Π½Π³ΠΈ Bronze 5 β Champion 1 ΠΎΡΠΎΠ±ΡΠ°ΠΆΠ°ΡΡΡΡ Π² ordinal 1β40 ΡΠΎΠ»ΡΠΊΠΎ Π΄Π»Ρ Π²Π½ΡΡΡΠ΅Π½Π½ΠΈΡ
Π²ΡΡΠΈΡΠ»Π΅Π½ΠΈΠΉ. ΠΡΡΡΠΊΠΈΠ΅ ΠΎΠ³ΡΠ°Π½ΠΈΡΠ΅Π½ΠΈΡ ΠΎΠ±Π΅ΡΠΏΠ΅ΡΠΈΠ²Π°ΡΡ ΡΠΎΡΡΠ°Π² 1/2/2 ΠΈ ΡΠ½ΠΈΠΊΠ°Π»ΡΠ½ΠΎΡΡΡ ΠΈΠ³ΡΠΎΠΊΠΎΠ². ΠΡΠ³ΠΊΠΈΠ΅ ΡΡΡΠ°ΡΡ ΡΡΠΈΡΡΠ²Π°ΡΡ ΠΎΠ±ΡΠΈΠΉ ΠΈ ΡΠΎΠ»Π΅Π²ΠΎΠΉ ΡΠ°Π·Π±ΡΠΎΡ ΡΠ΅ΠΉΡΠΈΠ½Π³Π°, Π½Π°Π·Π½Π°ΡΠ΅Π½ΠΈΠ΅ Π²Π½Π΅ ΠΏΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅ΠΌΠΎΠΉ ΡΠΎΠ»ΠΈ, ΡΠ°Π·Π΄Π΅Π»Π΅Π½ΠΈΠ΅ ΠΆΠ΅Π»Π°Π΅ΠΌΡΡ
ΡΠΎΡΠ·Π½ΠΈΠΊΠΎΠ² ΠΈ ΠΏΠΎΠΏΠ°Π΄Π°Π½ΠΈΠ΅ avoid-ΠΏΠ°ΡΡ Π² ΠΎΠ΄Π½Ρ ΠΊΠΎΠΌΠ°Π½Π΄Ρ. Avoid ΠΈΠΌΠ΅Π΅Ρ Π±ΠΎΠ»ΡΡΠΈΠΉ ΠΌΡΠ³ΠΊΠΈΠΉ Π²Π΅Ρ, ΡΠ΅ΠΌ ΠΏΠΎΠΆΠ΅Π»Π°Π½ΠΈΠ΅ ΠΈΠ³ΡΠ°ΡΡ Π²ΠΌΠ΅ΡΡΠ΅, Π½ΠΎ Π½Π΅ Π½Π°ΡΡΡΠ°Π΅Ρ ΡΡΠ΅Π±ΠΎΠ²Π°Π½ΠΈΡ Π±Π°Π»Π°Π½ΡΠ°. ΠΠΎΡΠ»Π΅ ΠΏΠΎΡΡΡΠΎΠ΅Π½ΠΈΡ Π½Π΅ΡΠΊΠΎΠ»ΡΠΊΠΈΡ
Π½Π°ΡΠ°Π»ΡΠ½ΡΡ
Π²Π°ΡΠΈΠ°Π½ΡΠΎΠ² Π²ΡΠΏΠΎΠ»Π½ΡΠ΅ΡΡΡ Π»ΠΎΠΊΠ°Π»ΡΠ½ΡΠΉ ΠΏΠΎΠΈΡΠΊ ΠΎΠ±ΠΌΠ΅Π½Π°ΠΌΠΈ ΠΈΠ³ΡΠΎΠΊΠΎΠ² ΠΎΠ΄Π½ΠΎΠΉ ΡΠΎΠ»ΠΈ.
ΠΠ»Π³ΠΎΡΠΈΡΠΌ ΡΠ°ΡΠΏΠΎΠ»Π°Π³Π°Π΅ΡΡΡ Π² Π΄ΠΎΠΌΠ΅Π½Π½ΠΎΠΌ ΡΠ»ΠΎΠ΅ ΠΈ Π½Π΅ Π·Π°Π²ΠΈΡΠΈΡ ΠΎΡ ΠΠ. ΠΡΠΈ ΡΠΎΡΡΠ΅ ΡΠΎΠΎΠ±ΡΠ΅ΡΡΠ²Π° Π΅Π³ΠΎ ΠΌΠΎΠΆΠ½ΠΎ Π·Π°ΠΌΠ΅Π½ΠΈΡΡ Π½Π° CP-SAT/MILP solver Π±Π΅Π· ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΡ API ΡΡΠ΅Π½Π°ΡΠΈΡ.
+### ΠΡΠΊΠ΅ΡΡΡΠ°ΡΠΈΡ ΡΠΎΠ±ΡΡΠΈΡ ΠΈ ΡΠ΅ΡΠΈΠΈ
+
+Application-ΡΠ»ΠΎΠΉ ΡΠΏΡΠ°Π²Π»ΡΠ΅Ρ Π΅Π΄ΠΈΠ½ΡΠΌ versioned workflow ΡΠΎΠ±ΡΡΠΈΡ. Π‘ΠΎΡΡΠΎΡΠ½ΠΈΠ΅ ΡΠΎΠ±ΡΡΠΈΡ, Π²ΡΠ±ΡΠ°Π½Π½ΡΠ΅ ΠΊΠΎΠΌΠ°Π½Π΄Ρ, ΡΠ΅Π·Π΅ΡΠ² ΠΈ Π°ΠΊΡΠΈΠ²Π½Π°Ρ ΡΠ΅ΡΠΈΡ ΡΠΎΡ
ΡΠ°Π½ΡΡΡΡΡ ΡΡΠ°Π½Π·Π°ΠΊΡΠΈΠΎΠ½Π½ΠΎ. ΠΠΎΠΌΠ°Π½Π΄Ρ `close-registration`, `select-balance`, `edit-rosters`, `confirm-rosters` ΠΈ `start-scrim` ΠΏΡΠΎΠ²Π΅ΡΡΡΡ ΡΠ΅ΠΊΡΡΠ΅Π΅ ΡΠΎΡΡΠΎΡΠ½ΠΈΠ΅ ΠΈ `expectedVersion`, ΠΏΠΎΡΡΠΎΠΌΡ ΠΏΠΎΠ²ΡΠΎΡΠ½ΡΠ΅ ΠΊΠ»ΠΈΠΊΠΈ ΠΈ ΠΊΠΎΠ½ΠΊΡΡΠΈΡΡΡΡΠΈΠ΅ Π²ΠΊΠ»Π°Π΄ΠΊΠΈ Π½Π΅ ΡΠΎΠ·Π΄Π°ΡΡ Π΄Π²Π΅ ΡΠ΅ΡΠΈΠΈ.
+
+Π ΡΡΠ½ΠΎΠ΅ ΡΠ΅Π΄Π°ΠΊΡΠΈΡΠΎΠ²Π°Π½ΠΈΠ΅ ΡΠ°Π±ΠΎΡΠ°Π΅Ρ Ρ ΡΠ΅ΡΠ²Π΅ΡΠ½ΡΠΌ roster draft. Backend ΡΠ°Π·ΡΠ΅ΡΠ°Π΅Ρ ΠΎΠ±ΠΌΠ΅Π½ ΡΠΎΠ»ΡΠΊΠΎ ΠΌΠ΅ΠΆΠ΄Ρ ΠΎΠ΄ΠΈΠ½Π°ΠΊΠΎΠ²ΡΠΌΠΈ ΡΠΎΠ»Π΅Π²ΡΠΌΠΈ ΡΠ»ΠΎΡΠ°ΠΌΠΈ ΠΈ Π·Π°ΠΌΠ΅Π½Ρ ΡΠ»ΠΎΡΠ° ΠΈΠ³ΡΠΎΠΊΠΎΠΌ ΠΈΠ· ΡΠ΅Π·Π΅ΡΠ²Π°, ΠΏΠΎΡΠ»Π΅ ΡΠ΅Π³ΠΎ ΠΏΠ΅ΡΠ΅ΡΡΠΈΡΡΠ²Π°Π΅Ρ ΡΡΠ΅Π΄Π½ΠΈΠ΅ ΡΠ΅ΠΉΡΠΈΠ½Π³ΠΈ ΠΈ ΠΌΠ΅ΡΡΠΈΠΊΠΈ. ΠΠΎΠ΄ΡΠ²Π΅ΡΠΆΠ΄Π΅Π½ΠΈΠ΅ ΡΡΠ΅Π±ΡΠ΅Ρ ΠΏΠΎΠ»Π½ΠΎΠ³ΠΎ ΡΠΎΡΡΠ°Π²Π° 1/2/2, ΡΠ½ΠΈΠΊΠ°Π»ΡΠ½ΡΡ
ΠΈΠ³ΡΠΎΠΊΠΎΠ² ΠΈ ΠΊΠ°ΠΏΠΈΡΠ°Π½Π° Π²Π½ΡΡΡΠΈ ΠΊΠ°ΠΆΠ΄ΠΎΠΉ ΠΊΠΎΠΌΠ°Π½Π΄Ρ.
+
+`start-scrim` Π°ΡΠΎΠΌΠ°ΡΠ½ΠΎ Π±Π»ΠΎΠΊΠΈΡΡΠ΅Ρ ΡΠΎΡΡΠ°Π²Ρ, ΡΠΎΠ·Π΄Π°ΡΡ ΠΎΠ΄Π½Ρ ΠΈΠ»ΠΈ Π½Π΅ΡΠΊΠΎΠ»ΡΠΊΠΎ Bo3-ΡΠ΅ΡΠΈΠΉ ΠΈ ΠΏΠ΅ΡΠ²ΡΠΉ draft state. ΠΠ»Ρ Π΄Π²ΡΡ
ΠΊΠΎΠΌΠ°Π½Π΄ ΡΠΎΠ·Π΄Π°ΡΡΡΡ ΠΎΠ΄ΠΈΠ½ΠΎΡΠ½Π°Ρ ΡΠ΅ΡΠΈΡ; Π΄Π»Ρ ΡΠ΅ΡΡΡΡΡ
ΠΈ Π±ΠΎΠ»Π΅Π΅ ΠΏΠΎΠ΄ΡΠ²Π΅ΡΠΆΠ΄ΡΠ½Π½ΡΡ
ΠΊΠΎΠΌΠ°Π½Π΄ ΡΠΎΠ·Π΄Π°ΡΡΡΡ single-elimination ΡΡΡΠ½ΠΈΡ. ID Π°ΠΊΡΠΈΠ²Π½ΠΎΠΉ ΡΠ΅ΡΠΈΠΈ Π²ΠΎΠ·Π²ΡΠ°ΡΠ°Π΅ΡΡΡ frontend, ΠΊΠΎΡΠΎΡΡΠΉ ΠΏΠ΅ΡΠ΅Ρ
ΠΎΠ΄ΠΈΡ Π½Π° live-ΡΠΊΡΠ°Π½.
+
### ΠΡΠ°ΡΡ ΠΊΠ°ΠΊ ΠΊΠΎΠ½Π΅ΡΠ½ΡΠΉ Π°Π²ΡΠΎΠΌΠ°Ρ
-ΠΠ΅ΡΠ΅Π±ΡΡΠ²ΠΊΠ° ΠΈ Π±Π°Π½Ρ ΠΌΠΎΠ΄Π΅Π»ΠΈΡΡΡΡΡΡ ΡΠΎΡΡΠΎΡΠ½ΠΈΡΠΌΠΈ ΠΈ Π΄ΠΎΠΏΡΡΡΠΈΠΌΡΠΌΠΈ ΠΊΠΎΠΌΠ°Π½Π΄Π°ΠΌΠΈ. Π‘Π΅ΡΠ²Π΅Ρ ΡΠ²Π»ΡΠ΅ΡΡΡ ΠΈΡΡΠΎΡΠ½ΠΈΠΊΠΎΠΌ ΠΈΡΡΠΈΠ½Ρ ΠΈ ΠΎΡΠΊΠ»ΠΎΠ½ΡΠ΅Ρ Π΄Π΅ΠΉΡΡΠ²ΠΈΠ΅, Π΅ΡΠ»ΠΈ ΠΎΠ½ΠΎ Π½Π°ΡΡΡΠ°Π΅Ρ ΠΏΠΎΡΡΠ΄ΠΎΠΊ ΠΈΠ»ΠΈ ΡΠ΅Π³Π»Π°ΠΌΠ΅Π½Ρ.
+ΠΠ΅ΡΠ΅Π±ΡΡΠ²ΠΊΠ° ΠΈ Π±Π°Π½Ρ ΠΌΠΎΠ΄Π΅Π»ΠΈΡΡΡΡΡΡ ΡΠΎΡΡΠΎΡΠ½ΠΈΡΠΌΠΈ ΠΈ Π΄ΠΎΠΏΡΡΡΠΈΠΌΡΠΌΠΈ ΠΊΠΎΠΌΠ°Π½Π΄Π°ΠΌΠΈ. ΠΠΎΡΠ»Π΅ ΠΆΠ΅ΡΠ΅Π±ΡΡΠ²ΠΊΠΈ ΠΏΠΎΠ±Π΅Π΄ΠΈΡΠ΅Π»Ρ ΠΏΠΎΠ»ΡΡΠ°Π΅Ρ ΠΏΠ΅ΡΠ²ΡΠΉ Π±Π°Π½ Π΄Π»Ρ ΠΏΠ΅ΡΠ²ΠΎΠΉ ΠΊΠ°ΡΡΡ, Π° ΠΏΡΠΎΠΈΠ³ΡΠ°Π²ΡΠΈΠΉ β ΠΏΠ΅ΡΠ²ΡΠΉ Π±Π°Π½ Π³Π΅ΡΠΎΠ΅Π². ΠΠ΅ΡΠ²Π°Ρ ΠΊΠ°ΡΡΠ° ΠΎΠΏΡΠ΅Π΄Π΅Π»ΡΠ΅ΡΡΡ map draft. ΠΠΎΡΠ»Π΅ ΡΠ΅Π·ΡΠ»ΡΡΠ°ΡΠ° Π²ΡΠΎΡΠΎΠΉ ΠΈ ΠΏΠΎΡΠ»Π΅Π΄ΡΡΡΠΈΡ
ΠΊΠ°ΡΡ FSM ΠΏΠ΅ΡΠ΅Ρ
ΠΎΠ΄ΠΈΡ Π² `MapPick`, Π³Π΄Π΅ ΠΏΡΠΎΠΈΠ³ΡΠ°Π²ΡΠ°Ρ ΠΊΠΎΠΌΠ°Π½Π΄Π° Π½Π°ΠΏΡΡΠΌΡΡ Π²ΡΠ±ΠΈΡΠ°Π΅Ρ ΠΊΠ°ΡΡΡ ΠΈΠ· ΡΠ»Π΅Π΄ΡΡΡΠ΅Π³ΠΎ ΠΏΡΠ»Π°; Π·Π°ΡΠ΅ΠΌ Π·Π°ΠΏΡΡΠΊΠ°Π΅ΡΡΡ hero draft. ΠΡΠΈ Π½ΠΈΡΡΠ΅ΠΉ ΠΏΡΠ°Π²ΠΎ Π²ΡΠ±ΠΎΡΠ° ΠΏΠΎΠ»ΡΡΠ°Π΅Ρ ΠΏΡΠΎΠΈΠ³ΡΠ°Π²ΡΠΈΠΉ Π½Π°ΡΠ°Π»ΡΠ½ΡΡ ΠΆΠ΅ΡΠ΅Π±ΡΡΠ²ΠΊΡ. Π‘Π΅ΡΠ²Π΅Ρ ΡΠ²Π»ΡΠ΅ΡΡΡ ΠΈΡΡΠΎΡΠ½ΠΈΠΊΠΎΠΌ ΠΈΡΡΠΈΠ½Ρ ΠΈ ΠΎΡΠΊΠ»ΠΎΠ½ΡΠ΅Ρ Π΄Π΅ΠΉΡΡΠ²ΠΈΠ΅, Π΅ΡΠ»ΠΈ ΠΎΠ½ΠΎ Π½Π°ΡΡΡΠ°Π΅Ρ ΠΏΠΎΡΡΠ΄ΠΎΠΊ, Π²Π΅ΡΡΠΈΡ ΠΈΠ»ΠΈ ΡΠ΅Π³Π»Π°ΠΌΠ΅Π½Ρ.
### ΠΡΡΠΎΡΠΈΡ Π΄Π΅ΠΉΡΡΠ²ΠΈΠΉ
@@ -98,7 +106,11 @@ frontend/src/
### ΠΠ°Π»Π΅Π½Π΄Π°ΡΡ ΠΈ ΡΡΠ°ΡΡΠΈΠ΅
-Π‘ΠΎΠ±ΡΡΠΈΡ Ρ
ΡΠ°Π½ΡΡΡΡ Π² UTC ΠΈ ΠΎΡΠΎΠ±ΡΠ°ΠΆΠ°ΡΡΡΡ Π² Π»ΠΎΠΊΠ°Π»ΡΠ½ΠΎΠΌ ΡΠ°ΡΠΎΠ²ΠΎΠΌ ΠΏΠΎΡΡΠ΅ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»Ρ. REST API ΠΎΡΠ΄Π°ΡΡ Π±ΡΠ΄ΡΡΠΈΠ΅ ΡΠΎΠ±ΡΡΠΈΡ ΠΈ ΡΡΠ°ΡΡΡΡ Going/Maybe/NotGoing. ΠΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΡ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΠΈ ΠΏΡΠ±Π»ΠΈΠΊΡΡΡΡΡ ΡΠ΅ΡΠ΅Π· realtime-ΠΊΠ°Π½Π°Π», ΡΡΠΎΠ±Ρ Admin ΡΡΠ°Π·Ρ Π²ΠΈΠ΄Π΅Π» Π°ΠΊΡΡΠ°Π»ΡΠ½ΡΠΉ ΡΠΏΠΈΡΠΎΠΊ. ΠΡΠΈ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΈ ΡΡΠ°ΡΡΡΠ° Π·Π° Π΄ΡΡΠ³ΠΎΠ³ΠΎ ΠΈΠ³ΡΠΎΠΊΠ° API ΡΠΎΡ
ΡΠ°Π½ΡΠ΅Ρ actor ID ΠΈ ΠΈΡΡΠΎΡΠ½ΠΈΠΊ `admin`, Π° UI ΠΏΠΎΠΊΠ°Π·ΡΠ²Π°Π΅Ρ, ΠΊΠ΅ΠΌ ΡΠ΄Π΅Π»Π°Π½Π° ΠΎΡΠΌΠ΅ΡΠΊΠ°.
+Π‘ΠΎΠ±ΡΡΠΈΡ Ρ
ΡΠ°Π½ΡΡΡΡ Π² UTC ΠΈ ΠΎΡΠΎΠ±ΡΠ°ΠΆΠ°ΡΡΡΡ Π² Π»ΠΎΠΊΠ°Π»ΡΠ½ΠΎΠΌ ΡΠ°ΡΠΎΠ²ΠΎΠΌ ΠΏΠΎΡΡΠ΅ ΠΏΠΎΠ»ΡΠ·ΠΎΠ²Π°ΡΠ΅Π»Ρ. REST API ΠΎΡΠ΄Π°ΡΡ Π±ΡΠ΄ΡΡΠΈΠ΅ ΠΈ ΠΏΡΠΎΡΠ΅Π΄ΡΠΈΠ΅ ΡΠΎΠ±ΡΡΠΈΡ ΠΈ ΡΡΠ°ΡΡΡΡ Going/Maybe/NotGoing. ΠΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΡ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΠΈ ΠΏΡΠ±Π»ΠΈΠΊΡΡΡΡΡ ΡΠ΅ΡΠ΅Π· realtime-ΠΊΠ°Π½Π°Π», ΡΡΠΎΠ±Ρ Admin ΡΡΠ°Π·Ρ Π²ΠΈΠ΄Π΅Π» Π°ΠΊΡΡΠ°Π»ΡΠ½ΡΠΉ ΡΠΏΠΈΡΠΎΠΊ. ΠΡΠΈ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΈ ΡΡΠ°ΡΡΡΠ° Π·Π° Π΄ΡΡΠ³ΠΎΠ³ΠΎ ΠΈΠ³ΡΠΎΠΊΠ° API ΡΠΎΡ
ΡΠ°Π½ΡΠ΅Ρ actor ID ΠΈ ΠΈΡΡΠΎΡΠ½ΠΈΠΊ `admin`, Π° UI ΠΏΠΎΠΊΠ°Π·ΡΠ²Π°Π΅Ρ, ΠΊΠ΅ΠΌ ΡΠ΄Π΅Π»Π°Π½Π° ΠΎΡΠΌΠ΅ΡΠΊΠ°.
+
+### Realtime-ΡΠΈΠ½Ρ
ΡΠΎΠ½ΠΈΠ·Π°ΡΠΈΡ
+
+ΠΠ΄ΠΈΠ½ Π³Π»ΠΎΠ±Π°Π»ΡΠ½ΡΠΉ authenticated SSE-ΠΊΠ°Π½Π°Π» ΠΏΠΎΠ΄ΠΏΠΈΡΡΠ²Π°Π΅ΡΡΡ Π½Π° wildcard topic ΠΈ Π°Π΄ΡΠ΅ΡΠ½ΠΎ ΠΈΠ½Π²Π°Π»ΠΈΠ΄ΠΈΡΡΠ΅Ρ TanStack Query-ΠΊΡΡ Π΄Π»Ρ ΡΠΎΠ±ΡΡΠΈΠΉ, RSVP, ΡΠΎΡΡΠ°Π²ΠΎΠ², ΡΠ΅ΡΠΈΠΉ, ΡΡΡΠ½ΠΈΡΠΎΠ² ΠΈ ΠΈΠ³ΡΠΎΠΊΠΎΠ². ΠΠΎΡΡΠΎΠΌΡ Π½ΠΎΠ²ΡΠ΅ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΠΈ, ΡΡΠ°ΡΡΠ½ΠΈΠΊΠΈ, ΠΆΠ΅ΡΠ΅Π±ΡΡΠ²ΠΊΠ°, Π±Π°Π½Ρ, ΡΠ΅Π·ΡΠ»ΡΡΠ°ΡΡ ΠΈ ΡΠ΅ΡΠΊΠ° ΠΏΠΎΡΠ²Π»ΡΡΡΡΡ Π±Π΅Π· ΠΏΠ΅ΡΠ΅Π·Π°Π³ΡΡΠ·ΠΊΠΈ. WebSocket Π½Π΅ ΡΡΠ΅Π±ΡΠ΅ΡΡΡ: ΠΊΠΎΠΌΠ°Π½Π΄Ρ ΠΎΡΡΠ°ΡΡΡΡ ΠΎΠ±ΡΡΠ½ΡΠΌΠΈ HTTP mutations, Π° ΡΠ΅ΡΠ²Π΅ΡΠ½ΡΠ΅ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΡ ΠΏΠ΅ΡΠ΅Π΄Π°ΡΡΡΡ ΠΊΠ»ΠΈΠ΅Π½ΡΠ°ΠΌ ΠΎΠ΄Π½ΠΎΠ½Π°ΠΏΡΠ°Π²Π»Π΅Π½Π½ΠΎ ΡΠ΅ΡΠ΅Π· SSE. Nginx ΠΎΡΠΊΠ»ΡΡΠ°Π΅Ρ buffering ΠΈ Π΄Π΅ΡΠΆΠΈΡ ΡΠΎΠ΅Π΄ΠΈΠ½Π΅Π½ΠΈΠ΅ ΠΎΡΠΊΡΡΡΡΠΌ.
### ΠΠ°ΠΏΠΈΡΠ°Π½Ρ
diff --git a/memory_bank/domain_model.md b/memory_bank/domain_model.md
index 7c5cbd9..e9521d8 100644
--- a/memory_bank/domain_model.md
+++ b/memory_bank/domain_model.md
@@ -6,7 +6,7 @@
ΠΠ³ΡΠΎΠΊ ΡΠ²ΡΠ·Π°Π½ Ρ Discord-Π°ΠΊΠΊΠ°ΡΠ½ΡΠΎΠΌ ΠΈ ΠΈΠΌΠ΅Π΅Ρ ΠΎΡΠΎΠ±ΡΠ°ΠΆΠ°Π΅ΠΌΠΎΠ΅ ΠΈΠΌΡ ΠΈ ΡΠΎΠ±ΡΡΠ²Π΅Π½Π½ΡΠΉ Overwatch-ΡΠ°Π½Π³ Π΄Π»Ρ ΠΊΠ°ΠΆΠ΄ΠΎΠΉ ΡΠΎΠ»ΠΈ: Tank, Damage, Support. ΠΠ΅ΡΡΠ½ΠΈΡΠ° ΡΠΎΠ΄Π΅ΡΠΆΠΈΡ 40 Π·Π½Π°ΡΠ΅Π½ΠΈΠΉ ΠΎΡ Bronze 5 Π΄ΠΎ Champion 1: Bronze, Silver, Gold, Platinum, Diamond, Master, Grandmaster, Champion, ΠΊΠ°ΠΆΠ΄ΡΠΉ Ρ Π΄ΠΈΠ²ΠΈΠ·ΠΈΠΎΠ½Π°ΠΌΠΈ 5 β 1. ΠΠ»Ρ Π²ΡΡΠΈΡΠ»Π΅Π½ΠΈΠΉ Π·Π½Π°ΡΠ΅Π½ΠΈΡ ΠΏΡΠ΅ΠΎΠ±ΡΠ°Π·ΡΡΡΡΡ Π² ordinal 1β40.
-ΠΠ³ΡΠΎΠΊ ΡΠ΅Π΄Π°ΠΊΡΠΈΡΡΠ΅Ρ ΡΠ°Π½Π³ΠΈ ΡΠ°ΠΌΠΎΡΡΠΎΡΡΠ΅Π»ΡΠ½ΠΎ; Admin ΠΌΠΎΠΆΠ΅Ρ Π²ΠΈΠ΄Π΅ΡΡ Π²ΡΠ΅ΠΌΡ ΠΏΠΎΡΠ»Π΅Π΄Π½Π΅Π³ΠΎ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΡ. ΠΠ³ΡΠΎΠΊ Π²ΡΠ±ΠΈΡΠ°Π΅Ρ Π»ΡΠ±ΠΎΠ΅ ΡΠΈΡΠ»ΠΎ ΠΏΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅ΠΌΡΡ
ΡΠΎΠ»Π΅ΠΉ ΠΈ Π΄ΠΎ ΡΡΡΡ
ΠΆΠ΅Π»Π°Π΅ΠΌΡΡ
ΡΠΎΡΠ·Π½ΠΈΠΊΠΎΠ². ΠΡΠ΅Π΄ΠΏΠΎΡΡΠ΅Π½ΠΈΡ ΡΠ²Π»ΡΡΡΡΡ ΠΌΡΠ³ΠΊΠΈΠΌΠΈ ΠΏΠΎΠΆΠ΅Π»Π°Π½ΠΈΡΠΌΠΈ, Π° Π½Π΅ Π³Π°ΡΠ°Π½ΡΠΈΠ΅ΠΉ ΡΠΎΡΡΠ°Π²Π°.
+ΠΠ³ΡΠΎΠΊ ΡΠ΅Π΄Π°ΠΊΡΠΈΡΡΠ΅Ρ ΡΠ°Π½Π³ΠΈ ΡΠ°ΠΌΠΎΡΡΠΎΡΡΠ΅Π»ΡΠ½ΠΎ; Admin ΠΌΠΎΠΆΠ΅Ρ Π²ΠΈΠ΄Π΅ΡΡ Π²ΡΠ΅ΠΌΡ ΠΏΠΎΡΠ»Π΅Π΄Π½Π΅Π³ΠΎ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΡ. ΠΠ³ΡΠΎΠΊ Π²ΡΠ±ΠΈΡΠ°Π΅Ρ Π»ΡΠ±ΠΎΠ΅ ΡΠΈΡΠ»ΠΎ ΠΏΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅ΠΌΡΡ
ΡΠΎΠ»Π΅ΠΉ, Π΄ΠΎ ΡΡΡΡ
ΠΆΠ΅Π»Π°Π΅ΠΌΡΡ
ΡΠΎΡΠ·Π½ΠΈΠΊΠΎΠ² ΠΈ Π΄ΠΎ ΡΡΡΡ
ΠΈΠ³ΡΠΎΠΊΠΎΠ² Π΄Π»Ρ avoid. ΠΡΠ΅Π΄ΠΏΠΎΡΡΠ΅Π½ΠΈΡ ΠΈ avoid ΡΠ²Π»ΡΡΡΡΡ ΠΌΡΠ³ΠΊΠΈΠΌΠΈ ΠΏΠΎΠΆΠ΅Π»Π°Π½ΠΈΡΠΌΠΈ, Π° Π½Π΅ Π³Π°ΡΠ°Π½ΡΠΈΠ΅ΠΉ ΡΠΎΡΡΠ°Π²Π°; ΠΎΠ΄ΠΈΠ½ ΠΈΠ³ΡΠΎΠΊ Π½Π΅ ΠΌΠΎΠΆΠ΅Ρ ΠΎΠ΄Π½ΠΎΠ²ΡΠ΅ΠΌΠ΅Π½Π½ΠΎ Π½Π°Ρ
ΠΎΠ΄ΠΈΡΡΡΡ Π² ΠΎΠ±ΠΎΠΈΡ
ΡΠΏΠΈΡΠΊΠ°Ρ
.
### Account
@@ -20,6 +20,21 @@
ΠΡΠΌΠ΅ΡΠΊΠ° ΠΈΠ³ΡΠΎΠΊΠ° Π² ΠΊΠ°Π»Π΅Π½Π΄Π°ΡΠ΅ ΡΠΎ ΡΡΠ°ΡΡΡΠΎΠΌ Going, Maybe ΠΈΠ»ΠΈ NotGoing. ΠΠ³ΡΠΎΠΊ ΠΌΠ΅Π½ΡΠ΅Ρ ΡΠΎΠ±ΡΡΠ²Π΅Π½Π½ΡΠΉ ΡΡΠ°ΡΡΡ, Π° Admin ΠΌΠΎΠΆΠ΅Ρ ΠΈΠ·ΠΌΠ΅Π½ΠΈΡΡ Π΅Π³ΠΎ Π·Π° ΠΈΠ³ΡΠΎΠΊΠ°. Π Π΅Π³ΠΈΡΡΡΠ°ΡΠΈΡ Ρ
ΡΠ°Π½ΠΈΡ Π°Π²ΡΠΎΡΠ° ΠΈ Π²ΡΠ΅ΠΌΡ ΠΏΠΎΡΠ»Π΅Π΄Π½Π΅Π³ΠΎ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΡ, ΡΡΠΎΠ±Ρ ΠΈΠ½ΡΠ΅ΡΡΠ΅ΠΉΡ ΡΠ²Π½ΠΎ ΠΏΠΎΠΊΠ°Π·ΡΠ²Π°Π» Π°Π΄ΠΌΠΈΠ½ΠΈΡΡΡΠ°ΡΠΈΠ²Π½ΠΎΠ΅ Π΄Π΅ΠΉΡΡΠ²ΠΈΠ΅. Π ΠΌΠΎΠΌΠ΅Π½Ρ Π·Π°ΠΊΡΡΡΠΈΡ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΠΈ Admin ΠΏΠΎΠ΄ΡΠ²Π΅ΡΠΆΠ΄Π°Π΅Ρ ΡΠΏΠΈΡΠΎΠΊ ΡΡΠ°ΡΡΠ½ΠΈΠΊΠΎΠ²; ΡΠΎΠ»ΡΠΊΠΎ Going-ΠΈΠ³ΡΠΎΠΊΠΈ ΠΏΠΎ ΡΠΌΠΎΠ»ΡΠ°Π½ΠΈΡ ΠΏΠΎΠΏΠ°Π΄Π°ΡΡ Π² Π±Π°Π»Π°Π½ΡΠΈΡΠΎΠ²ΠΊΡ.
+### ΠΠΈΠ·Π½Π΅Π½Π½ΡΠΉ ΡΠΈΠΊΠ» ΡΠΎΠ±ΡΡΠΈΡ
+
+Π‘ΠΎΠ±ΡΡΠΈΠ΅ ΠΏΡΠΎΡ
ΠΎΠ΄ΠΈΡ ΡΠ΅ΡΠ²Π΅ΡΠ½ΠΎ ΠΊΠΎΠ½ΡΡΠΎΠ»ΠΈΡΡΠ΅ΠΌΡΠ΅ ΡΠΎΡΡΠΎΡΠ½ΠΈΡ:
+
+1. `RegistrationOpen` β ΠΈΠ³ΡΠΎΠΊΠΈ ΠΌΠ΅Π½ΡΡΡ RSVP, Admin Π΄ΠΎΠ±Π°Π²Π»ΡΠ΅Ρ Π³ΠΎΡΡΠ΅ΠΉ;
+2. `RegistrationClosed` β ΡΠΏΠΈΡΠΎΠΊ ΡΡΠ°ΡΡΠ½ΠΈΠΊΠΎΠ² Π·Π°ΡΠΈΠΊΡΠΈΡΠΎΠ²Π°Π½, ΠΎΠ±ΡΡΠ½ΡΠ΅ RSVP Π·Π°Π±Π»ΠΎΠΊΠΈΡΠΎΠ²Π°Π½Ρ;
+3. `Balancing` β ΡΠΎΠ·Π΄Π°ΡΡΡΡ Π²Π°ΡΠΈΠ°Π½ΡΡ ΠΊΠΎΠΌΠ°Π½Π΄ ΠΈ ΡΠ΅Π·Π΅ΡΠ²;
+4. `RostersDraft` β Admin Π²ΡΠ±ΠΈΡΠ°Π΅Ρ Π²Π°ΡΠΈΠ°Π½Ρ, ΠΌΠ΅Π½ΡΠ΅Ρ ΠΈΠ³ΡΠΎΠΊΠΎΠ² ΠΎΠ΄Π½ΠΎΠΉ ΡΠΎΠ»ΠΈ ΠΌΠ΅ΡΡΠ°ΠΌΠΈ ΠΈ Π·Π°ΠΌΠ΅Π½ΡΠ΅Ρ ΠΈΡ
ΡΠ΅Π·Π΅ΡΠ²Π½ΡΠΌΠΈ;
+5. `RostersConfirmed` β ΡΠΎΡΡΠ°Π²Ρ 1/2/2 ΠΈ ΠΊΠ°ΠΏΠΈΡΠ°Π½Ρ ΠΏΡΠΎΠ²Π΅ΡΠ΅Π½Ρ ΠΈ Π·Π°Π±Π»ΠΎΠΊΠΈΡΠΎΠ²Π°Π½Ρ;
+6. `Live` β ΡΠΎΠ·Π΄Π°Π½Π° ΡΠ΅ΡΠΈΡ, ΡΠ°Π·ΡΠ΅ΡΠ΅Π½Ρ ΡΠΎΠ»ΡΠΊΠΎ Π΄Π΅ΠΉΡΡΠ²ΠΈΡ ΡΠ΅ΠΊΡΡΠ΅Π³ΠΎ ΡΠ°Π³Π° ΠΆΠ΅ΡΠ΅Π±ΡΡΠ²ΠΊΠΈ, Π±Π°Π½ΠΎΠ² ΠΈΠ»ΠΈ ΡΠ΅Π·ΡΠ»ΡΡΠ°ΡΠ°;
+7. `Completed` β ΡΠ΅ΡΠΈΡ ΠΈΠ»ΠΈ ΡΡΡΠ½ΠΈΡ Π·Π°Π²Π΅ΡΡΠ΅Π½Ρ;
+8. `Cancelled` β Admin ΠΎΡΠΌΠ΅Π½ΠΈΠ» ΡΠΎΠ±ΡΡΠΈΠ΅; ΠΎΠ½ΠΎ ΠΎΡΡΠ°ΡΡΡΡ Π² ΠΊΠ°Π»Π΅Π½Π΄Π°ΡΠ΅, Π½ΠΎ ΠΈΠ³ΡΠΎΠ²ΡΠ΅ Π΄Π΅ΠΉΡΡΠ²ΠΈΡ Π·Π°Π±Π»ΠΎΠΊΠΈΡΠΎΠ²Π°Π½Ρ.
+
+ΠΠ΅ΡΠ΅Ρ
ΠΎΠ΄Ρ Π²ΡΠΏΠΎΠ»Π½ΡΡΡΡΡ ΠΎΡΠ΄Π΅Π»ΡΠ½ΡΠΌΠΈ ΠΊΠΎΠΌΠ°Π½Π΄Π°ΠΌΠΈ API Ρ ΠΏΡΠΎΠ²Π΅ΡΠΊΠΎΠΉ ΠΎΠΆΠΈΠ΄Π°Π΅ΠΌΠΎΠΉ Π²Π΅ΡΡΠΈΠΈ. ΠΠ΅Π»ΡΠ·Ρ Π½Π°ΡΠ°ΡΡ ΡΠΊΡΠΈΠΌ Π±Π΅Π· ΠΌΠΈΠ½ΠΈΠΌΡΠΌ Π΄Π²ΡΡ
ΠΏΠΎΠ»Π½ΡΡ
ΠΊΠΎΠΌΠ°Π½Π΄, Π²Π°Π»ΠΈΠ΄Π½ΠΎΠ³ΠΎ ΡΠΎΡΡΠ°Π²Π° 1/2/2 ΠΈ ΠΊΠ°ΠΏΠΈΡΠ°Π½Π° Π² ΠΊΠ°ΠΆΠ΄ΠΎΠΉ ΠΊΠΎΠΌΠ°Π½Π΄Π΅. ΠΠΎΡΠ»Π΅ `RostersConfirmed` ΠΎΠ±ΡΡΠ½Π°Ρ ΠΏΠ΅ΡΠ΅ΡΡΠ°Π½ΠΎΠ²ΠΊΠ° Π·Π°ΠΏΡΠ΅ΡΠ΅Π½Π°. ΠΠ²Π°ΡΠΈΠΉΠ½Π°Ρ Π·Π°ΠΌΠ΅Π½Π° Π²ΠΎ Π²ΡΠ΅ΠΌΡ `Live` Π΄ΠΎΡΡΡΠΏΠ½Π° ΡΠΎΠ»ΡΠΊΠΎ Admin, Π΄ΠΎΠ»ΠΆΠ½Π° ΡΠΎΡ
ΡΠ°Π½ΡΡΡ ΡΠΎΠ»Π΅Π²ΠΎΠΉ ΡΠΎΡΡΠ°Π² ΠΈ Π·Π°ΠΏΠΈΡΡΠ²Π°Π΅ΡΡΡ Π² Π°ΡΠ΄ΠΈΡ. ΠΠΎΠ»Π½ΠΎΠ΅ ΡΠ΄Π°Π»Π΅Π½ΠΈΠ΅ ΡΠΎΠ±ΡΡΠΈΡ ΡΠ²Π»ΡΠ΅ΡΡΡ ΠΎΡΠ΄Π΅Π»ΡΠ½ΠΎΠΉ Admin-ΠΊΠΎΠΌΠ°Π½Π΄ΠΎΠΉ ΠΈ ΠΊΠ°ΡΠΊΠ°Π΄Π½ΠΎ ΡΠ΄Π°Π»ΡΠ΅Ρ Π΅Π³ΠΎ RSVP, ΡΠΎΡΡΠ°Π²Ρ, roster draft, ΡΠ΅ΡΠΈΠΈ, ΡΡΡΠ½ΠΈΡ ΠΈ event-scoped Π°ΡΠ΄ΠΈΡ.
+
### Team
Π‘ΠΎΡΡΠ°Π² ΠΈΠ³ΡΠΎΠΊΠΎΠ², Π½Π°Π·Π½Π°ΡΠ΅Π½Π½ΡΠΉ Admin ΠΊΠ°ΠΏΠΈΡΠ°Π½, ΡΠ²Π΅Ρ/Π½Π°Π·Π²Π°Π½ΠΈΠ΅ ΠΈ ΡΠ°ΡΡΡΠΈΡΠ°Π½Π½ΡΠ΅ ΠΏΠΎΠΊΠ°Π·Π°ΡΠ΅Π»ΠΈ ΡΠΈΠ»Ρ. ΠΠ°ΠΏΠΈΡΠ°Π½ Π΄ΠΎΠ»ΠΆΠ΅Π½ Π²Ρ
ΠΎΠ΄ΠΈΡΡ Π² ΡΠ΅ΠΊΡΡΠΈΠΉ ΡΠΎΡΡΠ°Π² ΠΊΠΎΠΌΠ°Π½Π΄Ρ; ΠΏΠ΅ΡΠ΅Π½Π°Π·Π½Π°ΡΠ΅Π½ΠΈΠ΅ ΡΠΈΠΊΡΠΈΡΡΠ΅ΡΡΡ Π² Π°ΡΠ΄ΠΈΡΠ΅.
@@ -50,7 +65,7 @@
### MapDraft
-ΠΠΎΡΠ°Π³ΠΎΠ²ΠΎΠ΅ ΠΈΡΠΊΠ»ΡΡΠ΅Π½ΠΈΠ΅ ΠΊΠ°ΡΡ ΠΈΠ· ΠΏΡΠ»Π° Π²ΡΠ±ΡΠ°Π½Π½ΠΎΠ³ΠΎ ΡΠ΅ΠΆΠΈΠΌΠ° Π΄ΠΎ ΠΎΠ΄Π½ΠΎΠΉ ΠΎΡΡΠ°Π²ΡΠ΅ΠΉΡΡ ΠΊΠ°ΡΡΡ. ΠΠ»Ρ ΡΠ°ΠΉΠ±ΡΠ΅ΠΉΠΊΠ΅ΡΠ° ΡΠ°Π½Π΅Π΅ ΡΡΠ³ΡΠ°Π½Π½Π°Ρ Control-ΠΊΠ°ΡΡΠ° ΠΈΡΠΊΠ»ΡΡΠ°Π΅ΡΡΡ.
+ΠΠ»Ρ ΠΏΠ΅ΡΠ²ΠΎΠΉ ΠΊΠ°ΡΡΡ β ΠΏΠΎΡΠ°Π³ΠΎΠ²ΠΎΠ΅ ΠΈΡΠΊΠ»ΡΡΠ΅Π½ΠΈΠ΅ ΠΊΠ°ΡΡ ΠΈΠ· ΠΏΡΠ»Π° Π΄ΠΎ ΠΎΠ΄Π½ΠΎΠΉ ΠΎΡΡΠ°Π²ΡΠ΅ΠΉΡΡ. ΠΡΠΎΡΡΡ ΠΈ ΠΏΠΎΡΠ»Π΅Π΄ΡΡΡΠΈΠ΅ ΠΊΠ°ΡΡΡ Π½Π°ΠΏΡΡΠΌΡΡ Π²ΡΠ±ΠΈΡΠ°Π΅Ρ ΠΊΠΎΠΌΠ°Π½Π΄Π°, ΠΏΡΠΎΠΈΠ³ΡΠ°Π²ΡΠ°Ρ ΠΏΡΠ΅Π΄ΡΠ΄ΡΡΡΡ ΠΊΠ°ΡΡΡ; ΠΏΠΎΡΠ»Π΅ Π½ΠΈΡΡΠ΅ΠΉ ΠΏΡΠ°Π²ΠΎ Π²ΡΠ±ΠΎΡΠ° ΠΏΠΎΠ»ΡΡΠ°Π΅Ρ ΠΊΠΎΠΌΠ°Π½Π΄Π°, ΠΏΡΠΎΠΈΠ³ΡΠ°Π²ΡΠ°Ρ Π½Π°ΡΠ°Π»ΡΠ½ΡΠΉ ΠΆΡΠ΅Π±ΠΈΠΉ. Π Π°Π½Π΅Π΅ ΡΡΠ³ΡΠ°Π½Π½ΡΠ΅ ΠΊΠ°ΡΡΡ ΠΈΡΠΊΠ»ΡΡΠ°ΡΡΡΡ.
### HeroDraft
@@ -72,7 +87,7 @@
ΠΠ»ΡΡΠ΅ΡΠ½Π°ΡΠΈΠ²Π½Π°Ρ ΠΏΠΎΡΠ»Π΅Π΄ΠΎΠ²Π°ΡΠ΅Π»ΡΠ½ΠΎΡΡΡ: Control β Push β Escort.
-ΠΠ΅ΡΠ²ΡΠΉ Π±Π°Π½ ΠΎΠΏΡΠ΅Π΄Π΅Π»ΡΠ΅ΡΡΡ ΠΆΠ΅ΡΠ΅Π±ΡΡΠ²ΠΊΠΎΠΉ. ΠΠ°ΡΠ΅ΠΌ ΠΊΠΎΠΌΠ°Π½Π΄Ρ ΠΏΠΎ ΠΎΡΠ΅ΡΠ΅Π΄ΠΈ ΠΈΡΠΊΠ»ΡΡΠ°ΡΡ ΠΊΠ°ΡΡΡ Π²Π½ΡΡΡΠΈ ΠΏΡΠ»Π° ΡΠ΅ΠΆΠΈΠΌΠ°, ΠΏΠΎΠΊΠ° Π½Π΅ ΠΎΡΡΠ°Π½Π΅ΡΡΡ ΠΎΠ΄Π½Π°.
+ΠΠ΅ΡΠ²ΡΠΉ Π±Π°Π½ ΠΏΠ΅ΡΠ²ΠΎΠΉ ΠΊΠ°ΡΡΡ ΠΎΠΏΡΠ΅Π΄Π΅Π»ΡΠ΅ΡΡΡ ΠΆΠ΅ΡΠ΅Π±ΡΡΠ²ΠΊΠΎΠΉ. ΠΠ°ΡΠ΅ΠΌ ΠΊΠΎΠΌΠ°Π½Π΄Ρ ΠΏΠΎ ΠΎΡΠ΅ΡΠ΅Π΄ΠΈ ΠΈΡΠΊΠ»ΡΡΠ°ΡΡ ΠΊΠ°ΡΡΡ Π²Π½ΡΡΡΠΈ ΠΏΡΠ»Π° ΡΠ΅ΠΆΠΈΠΌΠ°, ΠΏΠΎΠΊΠ° Π½Π΅ ΠΎΡΡΠ°Π½Π΅ΡΡΡ ΠΎΠ΄Π½Π°. ΠΠΎΡΠ»Π΅ Π·Π°Π²Π΅ΡΡΠ΅Π½ΠΈΡ ΠΊΠ°ΡΡΡ FSM ΠΏΠ΅ΡΠ΅Ρ
ΠΎΠ΄ΠΈΡ Π² `MapPick`: ΠΏΡΠΎΠΈΠ³ΡΠ°Π²ΡΠ°Ρ ΠΊΠΎΠΌΠ°Π½Π΄Π° Π²ΡΠ±ΠΈΡΠ°Π΅Ρ ΡΠ»Π΅Π΄ΡΡΡΡΡ Π΄ΠΎΡΡΡΠΏΠ½ΡΡ ΠΊΠ°ΡΡΡ, ΠΏΠΎΡΠ»Π΅ ΡΠ΅Π³ΠΎ Π½Π°ΡΠΈΠ½Π°Π΅ΡΡΡ hero draft.
### ΠΠ΅ΡΠΎΠΈ
@@ -89,6 +104,7 @@
- ΡΠ°Π·Π½ΠΈΡΡ ΡΡΠ΅Π΄Π½ΠΈΡ
ΡΠ΅ΠΉΡΠΈΠ½Π³ΠΎΠ² ΠΏΠΎ ΡΠΎΠ»ΡΠΌ;
- ΠΊΠΎΡΡΠ΅ΠΊΡΠ½ΠΎΡΡΡ ΡΠΎΡΡΠ°Π²Π° 1 Tank / 2 Damage / 2 Support;
- Π½Π°Π·Π½Π°ΡΠ΅Π½ΠΈΠ΅ ΠΈΠ³ΡΠΎΠΊΠΎΠ² Π½Π° ΠΏΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅ΠΌΡΠ΅ ΡΠΎΠ»ΠΈ;
+- ΡΠ°Π·Π²Π΅Π΄Π΅Π½ΠΈΠ΅ ΠΏΠΎ ΡΠ°Π·Π½ΡΠΌ ΠΊΠΎΠΌΠ°Π½Π΄Π°ΠΌ ΠΈΠ³ΡΠΎΠΊΠΎΠ² ΠΈΠ· avoid-ΡΠΏΠΈΡΠΊΠΎΠ²;
- ΠΏΠΎΠΏΠ°Π΄Π°Π½ΠΈΠ΅ Π²ΡΠ±ΡΠ°Π½Π½ΡΡ
ΠΈΠ³ΡΠΎΠΊΠΎΠΌ ΡΠΎΡΠ·Π½ΠΈΠΊΠΎΠ² Π² ΠΎΠ΄Π½Ρ ΠΊΠΎΠΌΠ°Π½Π΄Ρ;
- Π·Π°ΠΏΡΠ΅Ρ ΠΏΠΎΠ²ΡΠΎΡΠ΅Π½ΠΈΡ ΡΠΎΡΡΠ°Π²ΠΎΠ² ΠΏΡΠΈ ΡΠ΅Π³ΡΠ»ΡΡΠ½ΡΡ
ΠΈΠ³ΡΠ°Ρ
;
- Π·Π°ΡΠ°Π½Π΅Π΅ Π·Π°Π΄Π°Π½Π½ΡΠ΅ ΠΏΠ°ΡΡ ΠΈΠ»ΠΈ ΠΎΠ³ΡΠ°Π½ΠΈΡΠ΅Π½ΠΈΡ Β«Π²ΠΌΠ΅ΡΡΠ΅/ΡΠ°Π·Π΄Π΅Π»ΡΠ½ΠΎΒ».
@@ -97,6 +113,8 @@
ΠΡΡΡΠΊΠΈΠ΅ ΠΎΠ³ΡΠ°Π½ΠΈΡΠ΅Π½ΠΈΡ 1/2/2 ΠΈ ΡΠ½ΠΈΠΊΠ°Π»ΡΠ½ΠΎΡΡΠΈ ΠΈΠ³ΡΠΎΠΊΠΎΠ² Π²ΡΠ΅Π³Π΄Π° Π²Π°ΠΆΠ½Π΅Π΅ ΠΏΡΠ΅Π΄ΠΏΠΎΡΡΠ΅Π½ΠΈΠΉ. Π Π°Π·Π½ΠΈΡΠ° ΡΠ΅ΠΉΡΠΈΠ½Π³ΠΎΠ² ΠΈΠΌΠ΅Π΅Ρ Π±ΠΎΠ»ΡΡΠΈΠΉ Π²Π΅Ρ, ΡΠ΅ΠΌ ΠΏΠΎΠΆΠ΅Π»Π°Π½ΠΈΡ. Π MVP ΠΈΡΠΏΠΎΠ»ΡΠ·ΡΠ΅ΡΡΡ Π΄Π΅ΡΠ΅ΡΠΌΠΈΠ½ΠΈΡΠΎΠ²Π°Π½Π½Π°Ρ ΡΠ²ΡΠΈΡΡΠΈΠΊΠ°: Π½Π΅ΡΠΊΠΎΠ»ΡΠΊΠΎ Π½Π°ΡΠ°Π»ΡΠ½ΡΡ
Π½Π°Π·Π½Π°ΡΠ΅Π½ΠΈΠΉ, Π²Π·Π²Π΅ΡΠ΅Π½Π½Π°Ρ ΡΠ΅Π»Π΅Π²Π°Ρ ΡΡΠ½ΠΊΡΠΈΡ ΠΈ Π»ΠΎΠΊΠ°Π»ΡΠ½ΡΠ΅ ΠΎΠ±ΠΌΠ΅Π½Ρ ΠΈΠ³ΡΠΎΠΊΠΎΠ² ΠΎΠ΄Π½ΠΎΠΉ ΡΠΎΠ»ΠΈ. ΠΡΡ
ΠΈΡΠ΅ΠΊΡΡΡΠ° Π΄ΠΎΠΏΡΡΠΊΠ°Π΅Ρ Π·Π°ΠΌΠ΅Π½Ρ Π½Π° CP-SAT/MILP solver.
+Π ΡΡΠ½Π°Ρ ΠΊΠΎΡΡΠ΅ΠΊΡΠΈΡΠΎΠ²ΠΊΠ° Π½Π΅ ΠΈΠ·ΠΌΠ΅Π½ΡΠ΅Ρ ΠΈΡΡ
ΠΎΠ΄Π½ΡΠ΅ Π²Π°ΡΠΈΠ°Π½ΡΡ Π±Π°Π»Π°Π½ΡΠΈΡΠΎΠ²ΡΠΈΠΊΠ°: Π²ΡΠ±ΡΠ°Π½Π½ΡΠΉ Π²Π°ΡΠΈΠ°Π½Ρ ΠΊΠΎΠΏΠΈΡΡΠ΅ΡΡΡ Π² ΡΠ΅Π΄Π°ΠΊΡΠΈΡΡΠ΅ΠΌΡΠΉ roster draft. ΠΠ±ΠΌΠ΅Π½ Π΄ΠΎΠΏΡΡΠΊΠ°Π΅ΡΡΡ ΡΠΎΠ»ΡΠΊΠΎ ΠΌΠ΅ΠΆΠ΄Ρ ΡΠ»ΠΎΡΠ°ΠΌΠΈ ΠΎΠ΄ΠΈΠ½Π°ΠΊΠΎΠ²ΠΎΠΉ ΡΠΎΠ»ΠΈ. ΠΠ°ΠΌΠ΅Π½Π° ΠΈΠ· ΡΠ΅Π·Π΅ΡΠ²Π° Π½Π°Π·Π½Π°ΡΠ°Π΅Ρ ΠΈΠ³ΡΠΎΠΊΠ° Π² ΠΊΠΎΠ½ΠΊΡΠ΅ΡΠ½ΡΠΉ ΡΠΎΠ»Π΅Π²ΠΎΠΉ ΡΠ»ΠΎΡ ΠΈ ΠΏΠ΅ΡΠ΅ΡΡΠΈΡΡΠ²Π°Π΅Ρ ΠΏΠΎΠΊΠ°Π·Π°ΡΠ΅Π»ΠΈ ΠΎΠ±Π΅ΠΈΡ
ΠΊΠΎΠΌΠ°Π½Π΄ ΠΈ ΡΠ΅Π·Π΅ΡΠ²Π°.
+
## ΠΠΎΠΌΠ΅Π½Π½ΡΠ΅ ΡΠΎΠ±ΡΡΠΈΡ
- EventCreated;
diff --git a/memory_bank/project_context.md b/memory_bank/project_context.md
index c177402..8170622 100644
--- a/memory_bank/project_context.md
+++ b/memory_bank/project_context.md
@@ -8,7 +8,7 @@ Mixmaker β Π²Π΅Π±-ΠΏΡΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ Π΄Π»Ρ ΠΎΡΠ³Π°Π½ΠΈΠ·Π°ΡΠΈΠΈ Π»ΡΠ±ΠΈ
- ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΡ ΡΡΠ°ΡΡΠ½ΠΈΠΊΠΎΠ² ΠΈ ΠΎΡΠ΅Π½ΠΊΠ° ΠΈΡ
ΡΠΈΠ»Ρ ΠΏΠΎ ΡΠΎΠ»ΡΠΌ;
- Π²Ρ
ΠΎΠ΄ ΡΠ΅ΡΠ΅Π· Discord ΠΈ ΡΠ°ΠΌΠΎΡΡΠΎΡΡΠ΅Π»ΡΠ½ΡΠΉ Π²ΡΠ±ΠΎΡ ΡΠ°Π½Π³Π° Bronze 5 β Champion 1 Π΄Π»Ρ ΠΊΠ°ΠΆΠ΄ΠΎΠΉ ΡΠΎΠ»ΠΈ;
-- Π²ΡΠ±ΠΎΡ ΠΏΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅ΠΌΡΡ
ΡΠΎΠ»Π΅ΠΉ ΠΈ Π΄ΠΎ ΡΡΡΡ
ΠΆΠ΅Π»Π°Π΅ΠΌΡΡ
ΡΠΎΡΠ·Π½ΠΈΠΊΠΎΠ²;
+- Π²ΡΠ±ΠΎΡ ΠΏΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅ΠΌΡΡ
ΡΠΎΠ»Π΅ΠΉ, Π΄ΠΎ ΡΡΡΡ
ΠΆΠ΅Π»Π°Π΅ΠΌΡΡ
ΡΠΎΡΠ·Π½ΠΈΠΊΠΎΠ² ΠΈ Π΄ΠΎ ΡΡΡΡ
ΠΈΠ³ΡΠΎΠΊΠΎΠ², ΠΊΠΎΡΠΎΡΡΡ
ΠΆΠ΅Π»Π°ΡΠ΅Π»ΡΠ½ΠΎ ΠΈΠ·Π±Π΅Π³Π°ΡΡ;
- ΠΏΠ΅ΡΠ΅ΠΊΠ»ΡΡΠ΅Π½ΠΈΠ΅ ΠΈΠ½ΡΠ΅ΡΡΠ΅ΠΉΡΠ° ΠΌΠ΅ΠΆΠ΄Ρ ΡΡΡΡΠΊΠΈΠΌ ΠΈ Π°Π½Π³Π»ΠΈΠΉΡΠΊΠΈΠΌ ΡΠ·ΡΠΊΠΎΠΌ;
- ΠΊΠ°Π»Π΅Π½Π΄Π°ΡΡ Π·Π°ΠΏΠ»Π°Π½ΠΈΡΠΎΠ²Π°Π½Π½ΡΡ
ΠΌΠΈΠΊΡΠΎΠ² ΠΈ ΠΎΡΠΌΠ΅ΡΠΊΠΈ ΠΎΠ± ΡΡΠ°ΡΡΠΈΠΈ;
- Π°Π΄ΠΌΠΈΠ½ΠΈΡΡΡΠ°ΡΠΈΠ²Π½Π°Ρ ΠΏΠ°Π½Π΅Π»Ρ Π΄Π»Ρ ΡΠΏΡΠ°Π²Π»Π΅Π½ΠΈΡ ΡΠΎΠ±ΡΡΠΈΡΠΌΠΈ, ΡΡΠ°ΡΡΠ½ΠΈΠΊΠ°ΠΌΠΈ ΠΈ ΠΈΡ
ΠΎΡΠΌΠ΅ΡΠΊΠ°ΠΌΠΈ;
diff --git a/memory_bank/roadmap.md b/memory_bank/roadmap.md
index b505cb4..36add53 100644
--- a/memory_bank/roadmap.md
+++ b/memory_bank/roadmap.md
@@ -1,6 +1,20 @@
# ΠΠΎΡΠΎΠΆΠ½Π°Ρ ΠΊΠ°ΡΡΠ°
-Π‘ΡΠ°ΡΡΡ Π½Π° 19 ΠΈΡΠ»Ρ 2026: ΡΠ΅Ρ
Π½ΠΈΡΠ΅ΡΠΊΠΈΠΉ MVP ΡΡΠ°ΠΏΠΎΠ² 0β4 ΡΠ΅Π°Π»ΠΈΠ·ΠΎΠ²Π°Π½. Π‘Π»Π΅Π΄ΡΡΡΠΈΠΉ ΠΊΠΎΠ½ΡΡΠΎΠ»ΡΠ½ΡΠΉ ΡΡΠ±Π΅ΠΆ β ΡΠ°Π·Π²ΡΡΡΡΠ²Π°Π½ΠΈΠ΅ Π² Coolify ΠΈ ΠΏΡΠΈΡΠΌΠΎΡΠ½ΠΎΠ΅ ΡΠ΅ΡΡΠΈΡΠΎΠ²Π°Π½ΠΈΠ΅ Ρ ΡΠ΅Π°Π»ΡΠ½ΡΠΌΠΈ Discord-Π°ΠΊΠΊΠ°ΡΠ½ΡΠ°ΠΌΠΈ; ΡΠ»ΡΡΡΠ΅Π½ΠΈΡ ΡΡΠ°ΠΏΠ° 5 ΠΎΡΡΠ°ΡΡΡΡ Π±ΡΠ΄ΡΡΠ΅ΠΉ ΡΠ°Π±ΠΎΡΠΎΠΉ.
+Π‘ΡΠ°ΡΡΡ Π½Π° 19 ΠΈΡΠ»Ρ 2026: ΠΏΠΎΠ»Π½ΡΠΉ versioned production-ΠΏΠ°ΠΉΠΏΠ»Π°ΠΉΠ½ ΠΌΠ΅ΠΆΠ΄Ρ ΡΠΎΠ±ΡΡΠΈΠ΅ΠΌ, ΡΠΎΡΡΠ°Π²Π°ΠΌΠΈ, live-ΡΠ΅ΡΠΈΠ΅ΠΉ ΠΈ ΡΡΡΠ½ΠΈΡΠ½ΠΎΠΉ ΡΠ΅ΡΠΊΠΎΠΉ ΡΠ΅Π°Π»ΠΈΠ·ΠΎΠ²Π°Π½. Backend, admin roster editor, live/spectator UI, SSE ΠΈ ΠΏΡΠΎΠ²Π΅ΡΠΊΠΈ ΠΊΠΎΠ½ΡΡΠ°ΠΊΡΠ° ΡΠ²ΡΠ·Π°Π½Ρ Π² Π΅Π΄ΠΈΠ½ΡΠΉ ΡΡΠ΅Π½Π°ΡΠΈΠΉ.
+
+## ΠΠ°Π²Π΅ΡΡΡΠ½Π½ΡΠΉ ΠΊΠΎΠ½ΡΡΠΎΠ»ΡΠ½ΡΠΉ ΡΡΠ±Π΅ΠΆ β ΠΏΠΎΠ»Π½ΡΠΉ ΠΏΠ°ΠΉΠΏΠ»Π°ΠΉΠ½ ΡΠΊΡΠΈΠΌΠ°
+
+- [x] Π·Π°ΠΊΡΡΡΡ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΡ ΠΈ Π·Π°ΡΠΈΠΊΡΠΈΡΠΎΠ²Π°ΡΡ Going-ΡΡΠ°ΡΡΠ½ΠΈΠΊΠΎΠ²;
+- [x] ΡΠ³Π΅Π½Π΅ΡΠΈΡΠΎΠ²Π°ΡΡ ΠΈ Π²ΡΠ±ΡΠ°ΡΡ Π²Π°ΡΠΈΠ°Π½Ρ Π±Π°Π»Π°Π½ΡΠ°;
+- [x] Π΄Π°ΡΡ Admin ΠΎΠ±ΠΌΠ΅Π½ΠΈΠ²Π°ΡΡ ΠΈΠ³ΡΠΎΠΊΠΎΠ² ΠΎΠ΄ΠΈΠ½Π°ΠΊΠΎΠ²ΠΎΠΉ ΡΠΎΠ»ΠΈ ΠΈ Π·Π°ΠΌΠ΅Π½ΡΡΡ ΠΈΡ
ΡΠ΅Π·Π΅ΡΠ²Π½ΡΠΌΠΈ;
+- [x] ΠΏΠ΅ΡΠ΅ΡΡΠΈΡΡΠ²Π°ΡΡ ΠΏΠΎΠΊΠ°Π·Π°ΡΠ΅Π»ΠΈ ΠΏΠΎΡΠ»Π΅ ΠΊΠ°ΠΆΠ΄ΠΎΠΉ ΡΡΡΠ½ΠΎΠΉ ΠΏΡΠ°Π²ΠΊΠΈ;
+- [x] ΠΏΠΎΠ΄ΡΠ²Π΅ΡΠ΄ΠΈΡΡ ΡΠΎΡΡΠ°Π²Ρ 1/2/2 ΠΈ ΠΊΠ°ΠΏΠΈΡΠ°Π½ΠΎΠ²;
+- [x] Π·Π°ΠΏΡΡΠΊΠ°ΡΡ ΠΎΠ΄ΠΈΠ½ΠΎΡΠ½ΡΡ Bo3-ΡΠ΅ΡΠΈΡ Π΄Π»Ρ Π΄Π²ΡΡ
ΠΊΠΎΠΌΠ°Π½Π΄ ΠΈΠ»ΠΈ single-elimination ΡΡΡΠ½ΠΈΡ Π΄Π»Ρ Π½Π΅ΡΠΊΠΎΠ»ΡΠΊΠΈΡ
;
+- [x] ΡΠ²ΡΠ·Π°ΡΡ ΠΆΠ΅ΡΠ΅Π±ΡΡΠ²ΠΊΡ, map draft, hero draft ΠΈ ΡΠ΅Π·ΡΠ»ΡΡΠ°ΡΡ ΠΊΠ°ΡΡ Π΅Π΄ΠΈΠ½ΡΠΌ versioned FSM;
+- [x] Π±Π»ΠΎΠΊΠΈΡΠΎΠ²Π°ΡΡ ΠΎΠ±ΡΡΠ½ΠΎΠ΅ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠ΅ ΡΠΎΡΡΠ°Π²ΠΎΠ² ΠΏΠΎΡΠ»Π΅ ΡΡΠ°ΡΡΠ°;
+- [x] ΡΠ°Π·ΡΠ΅ΡΠΈΡΡ Π°ΡΠ΄ΠΈΡΡΠ΅ΠΌΡΡ Π°Π²Π°ΡΠΈΠΉΠ½ΡΡ Π·Π°ΠΌΠ΅Π½Ρ Admin;
+- [x] ΡΠΈΠ½Ρ
ΡΠΎΠ½ΠΈΠ·ΠΈΡΠΎΠ²Π°ΡΡ admin/live/spectator ΡΠΊΡΠ°Π½Ρ ΡΠ΅ΡΠ΅Π· SSE;
+- [x] ΠΏΠΎΠΊΡΡΡΡ ΠΏΠ΅ΡΠ΅Ρ
ΠΎΠ΄Ρ ΠΈ ΠΊΠΎΠ½ΡΠ»ΠΈΠΊΡΡ Π²Π΅ΡΡΠΈΠΉ unit-, integration- ΠΈ UI-ΡΠ΅ΡΡΠ°ΠΌΠΈ.
## ΠΡΠ°ΠΏ 0 β ΡΡΠΎΡΠ½Π΅Π½ΠΈΠ΅ ΠΏΡΠΎΠ΄ΡΠΊΡΠ°
@@ -29,7 +43,7 @@
## ΠΡΠ°ΠΏ 2 β ΠΈΠ³ΡΠΎΠΊΠΈ ΠΈ Π±Π°Π»Π°Π½ΡΠΈΡΠΎΠ²ΠΊΠ°
- ΡΠ°ΠΌΠΎΡΡΠΎΡΡΠ΅Π»ΡΠ½ΠΎΠ΅ Π·Π°ΠΏΠΎΠ»Π½Π΅Π½ΠΈΠ΅ ΠΏΡΠΎΡΠΈΠ»Ρ ΠΈ ΡΠ΅ΠΉΡΠΈΠ½Π³ΠΎΠ² ΠΏΠΎ ΡΠΎΠ»ΡΠΌ;
-- Π²ΡΠ±ΠΎΡ ΠΏΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅ΠΌΡΡ
ΡΠΎΠ»Π΅ΠΉ ΠΈ Π΄ΠΎ ΡΡΡΡ
ΠΆΠ΅Π»Π°Π΅ΠΌΡΡ
ΡΠΎΡΠ·Π½ΠΈΠΊΠΎΠ²;
+- Π²ΡΠ±ΠΎΡ ΠΏΡΠ΅Π΄ΠΏΠΎΡΠΈΡΠ°Π΅ΠΌΡΡ
ΡΠΎΠ»Π΅ΠΉ, Π΄ΠΎ ΡΡΡΡ
ΠΆΠ΅Π»Π°Π΅ΠΌΡΡ
ΡΠΎΡΠ·Π½ΠΈΠΊΠΎΠ² ΠΈ Π΄ΠΎ ΡΡΡΡ
avoid-ΠΈΠ³ΡΠΎΠΊΠΎΠ²;
- ΠΊΠ°Π»Π΅Π½Π΄Π°ΡΡ Π±ΡΠ΄ΡΡΠΈΡ
ΠΌΠΈΠΊΡΠΎΠ²;
- ΡΡΠ°ΡΡΡΡ ΡΡΠ°ΡΡΠΈΡ Going / Maybe / NotGoing;
- Π°Π΄ΠΌΠΈΠ½ΠΊΡ Π΄Π»Ρ ΡΠΎΠ±ΡΡΠΈΠΉ, ΠΈΠ³ΡΠΎΠΊΠΎΠ² ΠΈ ΡΠ΅Π³ΠΈΡΡΡΠ°ΡΠΈΠΉ;
@@ -38,7 +52,7 @@
- Π½Π°Π·Π½Π°ΡΠ΅Π½ΠΈΠ΅ Π΄ΠΎΡΡΡΠΏΠ½ΡΡ
ΡΠΎΠ»Π΅ΠΉ;
- Π³Π΅Π½Π΅ΡΠ°ΡΠΈΡ Π½Π΅ΡΠΊΠΎΠ»ΡΠΊΠΈΡ
Π²Π°ΡΠΈΠ°Π½ΡΠΎΠ² ΠΊΠΎΠΌΠ°Π½Π΄;
- ΠΎΡΠ΅Π½ΠΊΠ° ΠΊΠ°ΡΠ΅ΡΡΠ²Π° Π±Π°Π»Π°Π½ΡΠ°;
-- ΠΌΡΠ³ΠΊΠΈΠΉ ΡΡΡΡ ΡΠΎΠ»Π΅Π²ΡΡ
ΠΈ ΠΊΠΎΠΌΠ°Π½Π΄Π½ΡΡ
ΠΏΡΠ΅Π΄ΠΏΠΎΡΡΠ΅Π½ΠΈΠΉ Π² ΡΠ΅Π»Π΅Π²ΠΎΠΉ ΡΡΠ½ΠΊΡΠΈΠΈ;
+- ΠΌΡΠ³ΠΊΠΈΠΉ ΡΡΡΡ ΡΠΎΠ»Π΅Π²ΡΡ
, ΠΊΠΎΠΌΠ°Π½Π΄Π½ΡΡ
ΠΈ avoid-ΠΏΡΠ΅Π΄ΠΏΠΎΡΡΠ΅Π½ΠΈΠΉ Π² ΡΠ΅Π»Π΅Π²ΠΎΠΉ ΡΡΠ½ΠΊΡΠΈΠΈ;
- ΡΡΡΠ½Π°Ρ ΠΏΠ΅ΡΠ΅ΡΡΠ°Π½ΠΎΠ²ΠΊΠ° ΠΈΠ³ΡΠΎΠΊΠΎΠ² Ρ ΠΏΠ΅ΡΠ΅ΡΡΡΡΠΎΠΌ ΠΏΠΎΠΊΠ°Π·Π°ΡΠ΅Π»Π΅ΠΉ.
- Π½Π°Π·Π½Π°ΡΠ΅Π½ΠΈΠ΅ ΠΈ ΠΏΠ΅ΡΠ΅Π½Π°Π·Π½Π°ΡΠ΅Π½ΠΈΠ΅ ΠΊΠ°ΠΏΠΈΡΠ°Π½Π° ΠΊΠ°ΠΆΠ΄ΠΎΠΉ ΠΊΠΎΠΌΠ°Π½Π΄Ρ.
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index 0221b71..1466680 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -180,6 +180,28 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Event"
+ delete:
+ tags: [Events]
+ operationId: deleteEvent
+ description: Permanently removes the event and all event-scoped data.
+ parameters:
+ - $ref: "#/components/parameters/EventId"
+ responses:
+ "204": { description: Event deleted }
+ "403": { $ref: "#/components/responses/Forbidden" }
+ "404": { $ref: "#/components/responses/NotFound" }
+ /api/events/{eventId}/cancel:
+ post:
+ tags: [Events]
+ operationId: cancelEvent
+ parameters:
+ - $ref: "#/components/parameters/EventId"
+ requestBody:
+ required: true
+ content: { application/json: { schema: { $ref: "#/components/schemas/VersionCommand" } } }
+ responses:
+ "200": { description: Event cancelled, content: { application/json: { schema: { $ref: "#/components/schemas/Event" } } } }
+ "409": { $ref: "#/components/responses/Conflict" }
/api/events/{eventId}/rsvps:
get:
tags: [Events]
@@ -254,6 +276,19 @@ paths:
$ref: "#/components/schemas/EventRegistration"
"403":
$ref: "#/components/responses/Forbidden"
+ delete:
+ tags: [Events]
+ operationId: removeParticipantFromEvent
+ parameters:
+ - $ref: "#/components/parameters/EventId"
+ - $ref: "#/components/parameters/PlayerId"
+ responses:
+ "204":
+ description: Participant removed from the event
+ "403":
+ $ref: "#/components/responses/Forbidden"
+ "404":
+ $ref: "#/components/responses/NotFound"
/api/events/{eventId}/balance:
post:
tags: [Teams]
@@ -279,6 +314,150 @@ paths:
type: array
items:
$ref: "#/components/schemas/BalanceCandidate"
+ /api/events/{eventId}/registration/close:
+ post:
+ tags: [Events]
+ operationId: closeRegistration
+ parameters: [{ $ref: "#/components/parameters/EventId" }]
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CloseRegistrationCommand"
+ responses:
+ "200": { description: Registration closed, content: { application/json: { schema: { $ref: "#/components/schemas/Event" } } } }
+ "409": { $ref: "#/components/responses/Conflict" }
+ /api/events/{eventId}/balance/generate:
+ post:
+ tags: [Teams]
+ operationId: generateWorkflowBalance
+ parameters: [{ $ref: "#/components/parameters/EventId" }]
+ requestBody:
+ required: true
+ content: { application/json: { schema: { $ref: "#/components/schemas/VersionCommand" } } }
+ responses:
+ "200":
+ description: Event and generated candidates
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [event, candidates]
+ properties:
+ event: { $ref: "#/components/schemas/Event" }
+ candidates: { type: array, items: { $ref: "#/components/schemas/BalanceCandidate" } }
+ "409": { $ref: "#/components/responses/Conflict" }
+ /api/events/{eventId}/roster:
+ get:
+ tags: [Teams]
+ operationId: getRosterDraft
+ parameters: [{ $ref: "#/components/parameters/EventId" }]
+ responses:
+ "200": { description: Roster draft, content: { application/json: { schema: { $ref: "#/components/schemas/RosterDraft" } } } }
+ put:
+ tags: [Teams]
+ operationId: selectWorkflowBalance
+ parameters: [{ $ref: "#/components/parameters/EventId" }]
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [candidate, expectedVersion]
+ properties:
+ candidate: { $ref: "#/components/schemas/BalanceCandidate" }
+ expectedVersion: { type: integer }
+ responses:
+ "200": { description: Roster draft created, content: { application/json: { schema: { $ref: "#/components/schemas/RosterDraft" } } } }
+ "409": { $ref: "#/components/responses/Conflict" }
+ /api/events/{eventId}/roster/swap:
+ post:
+ tags: [Teams]
+ operationId: swapRosterPlayers
+ parameters: [{ $ref: "#/components/parameters/EventId" }]
+ requestBody:
+ required: true
+ content: { application/json: { schema: { $ref: "#/components/schemas/RosterSwapCommand" } } }
+ responses:
+ "200": { description: Updated roster, content: { application/json: { schema: { $ref: "#/components/schemas/RosterDraft" } } } }
+ "409": { $ref: "#/components/responses/Conflict" }
+ /api/events/{eventId}/roster/substitute:
+ post:
+ tags: [Teams]
+ operationId: substituteRosterPlayer
+ parameters: [{ $ref: "#/components/parameters/EventId" }]
+ requestBody:
+ required: true
+ content: { application/json: { schema: { $ref: "#/components/schemas/RosterSubstitutionCommand" } } }
+ responses:
+ "200": { description: Updated roster, content: { application/json: { schema: { $ref: "#/components/schemas/RosterDraft" } } } }
+ /api/events/{eventId}/roster/emergency-substitute:
+ post:
+ tags: [Teams]
+ operationId: emergencySubstituteRosterPlayer
+ parameters: [{ $ref: "#/components/parameters/EventId" }]
+ requestBody:
+ required: true
+ content: { application/json: { schema: { $ref: "#/components/schemas/RosterSubstitutionCommand" } } }
+ responses:
+ "200": { description: Updated live roster, content: { application/json: { schema: { $ref: "#/components/schemas/RosterDraft" } } } }
+ /api/events/{eventId}/roster/captain:
+ put:
+ tags: [Teams]
+ operationId: setRosterCaptain
+ parameters: [{ $ref: "#/components/parameters/EventId" }]
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [teamId, playerId, expectedVersion]
+ properties:
+ teamId: { type: string }
+ playerId: { type: string }
+ expectedVersion: { type: integer }
+ responses:
+ "200": { description: Updated roster, content: { application/json: { schema: { $ref: "#/components/schemas/RosterDraft" } } } }
+ /api/events/{eventId}/roster/confirm:
+ post:
+ tags: [Teams]
+ operationId: confirmRosters
+ parameters: [{ $ref: "#/components/parameters/EventId" }]
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [expectedVersion, expectedRosterVersion]
+ properties:
+ expectedVersion: { type: integer }
+ expectedRosterVersion: { type: integer }
+ responses:
+ "200": { description: Rosters confirmed, content: { application/json: { schema: { $ref: "#/components/schemas/Event" } } } }
+ /api/events/{eventId}/start:
+ post:
+ tags: [Events]
+ operationId: startScrim
+ parameters: [{ $ref: "#/components/parameters/EventId" }]
+ requestBody:
+ required: true
+ content: { application/json: { schema: { $ref: "#/components/schemas/VersionCommand" } } }
+ responses:
+ "201":
+ description: Scrim started
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [event]
+ properties:
+ event: { $ref: "#/components/schemas/Event" }
+ series: { $ref: "#/components/schemas/Series" }
+ tournament: { $ref: "#/components/schemas/Tournament" }
/api/teams/{teamId}/captain:
put:
tags: [Teams]
@@ -385,6 +564,84 @@ paths:
$ref: "#/components/schemas/Series"
"409":
$ref: "#/components/responses/Conflict"
+ /api/series/{seriesId}:
+ get:
+ tags: [Series]
+ operationId: getSeries
+ parameters: [{ $ref: "#/components/parameters/SeriesId" }]
+ responses:
+ "200": { description: Full live series state, content: { application/json: { schema: { $ref: "#/components/schemas/Series" } } } }
+ /api/series/{seriesId}/coin-toss:
+ post:
+ tags: [Series]
+ operationId: tossSeriesCoin
+ parameters: [{ $ref: "#/components/parameters/SeriesId" }]
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [seed, actingTeamId, expectedVersion]
+ properties:
+ seed: { type: string }
+ actingTeamId: { type: string }
+ expectedVersion: { type: integer }
+ responses:
+ "200": { description: Updated series, content: { application/json: { schema: { $ref: "#/components/schemas/Series" } } } }
+ "409": { $ref: "#/components/responses/Conflict" }
+ /api/series/{seriesId}/map-bans:
+ post:
+ tags: [Series]
+ operationId: banSeriesMap
+ parameters: [{ $ref: "#/components/parameters/SeriesId" }]
+ requestBody:
+ required: true
+ content: { application/json: { schema: { $ref: "#/components/schemas/SeriesBanCommand" } } }
+ responses:
+ "200": { description: Updated series, content: { application/json: { schema: { $ref: "#/components/schemas/Series" } } } }
+ "409": { $ref: "#/components/responses/Conflict" }
+ /api/series/{seriesId}/map-pick:
+ post:
+ tags: [Series]
+ operationId: pickSeriesMap
+ description: The previous map loser picks the next map directly.
+ parameters: [{ $ref: "#/components/parameters/SeriesId" }]
+ requestBody:
+ required: true
+ content: { application/json: { schema: { $ref: "#/components/schemas/SeriesBanCommand" } } }
+ responses:
+ "200": { description: Updated series with hero bans started, content: { application/json: { schema: { $ref: "#/components/schemas/Series" } } } }
+ "409": { $ref: "#/components/responses/Conflict" }
+ /api/series/{seriesId}/hero-bans:
+ post:
+ tags: [Series]
+ operationId: banSeriesHero
+ parameters: [{ $ref: "#/components/parameters/SeriesId" }]
+ requestBody:
+ required: true
+ content: { application/json: { schema: { $ref: "#/components/schemas/SeriesBanCommand" } } }
+ responses:
+ "200": { description: Updated series, content: { application/json: { schema: { $ref: "#/components/schemas/Series" } } } }
+ "409": { $ref: "#/components/responses/Conflict" }
+ /api/series/{seriesId}/map-result:
+ post:
+ tags: [Series]
+ operationId: recordSeriesMapResult
+ parameters: [{ $ref: "#/components/parameters/SeriesId" }]
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [outcome, actingTeamId, expectedVersion]
+ properties:
+ outcome: { type: string, enum: [TeamAWin, TeamBWin, Draw] }
+ actingTeamId: { type: string }
+ expectedVersion: { type: integer }
+ responses:
+ "200": { description: Updated series, content: { application/json: { schema: { $ref: "#/components/schemas/Series" } } } }
/api/series/{seriesId}/results:
post:
tags: [Series]
@@ -556,7 +813,7 @@ components:
description: Bronze 5 = 1, Champion 1 = 40
Player:
type: object
- required: [id, accountId, displayName, ratings, preferredRoles, preferredPlayerIds, updatedAt]
+ required: [id, accountId, displayName, ratings, preferredRoles, preferredPlayerIds, avoidedPlayerIds, updatedAt]
properties:
id:
type: string
@@ -578,6 +835,12 @@ components:
maxItems: 3
items:
type: string
+ avoidedPlayerIds:
+ type: array
+ maxItems: 3
+ description: Soft preference to place these players on another team.
+ items:
+ type: string
createdAt:
type: string
format: date-time
@@ -602,6 +865,11 @@ components:
maxItems: 3
items:
type: string
+ avoidedPlayerIds:
+ type: array
+ maxItems: 3
+ items:
+ type: string
EventInput:
type: object
required: [name, startsAt, endsAt, registrationDeadline]
@@ -624,7 +892,7 @@ components:
allOf:
- $ref: "#/components/schemas/EventInput"
- type: object
- required: [id, createdBy, createdAt, updatedAt]
+ required: [id, createdBy, createdAt, updatedAt, state, version, rulesetId, activeSeriesId, tournamentId]
properties:
id:
type: string
@@ -637,6 +905,13 @@ components:
updatedAt:
type: string
format: date-time
+ state:
+ type: string
+ enum: [RegistrationOpen, RegistrationClosed, Balancing, RostersDraft, RostersConfirmed, Live, Completed, Cancelled]
+ version: { type: integer }
+ rulesetId: { type: string }
+ activeSeriesId: { type: string }
+ tournamentId: { type: string }
RegistrationInput:
type: object
required: [status]
@@ -709,6 +984,51 @@ components:
type: array
items:
type: string
+ VersionCommand:
+ type: object
+ required: [expectedVersion]
+ properties:
+ expectedVersion: { type: integer }
+ CloseRegistrationCommand:
+ type: object
+ required: [rulesetId, expectedVersion]
+ properties:
+ rulesetId: { type: string }
+ expectedVersion: { type: integer }
+ RosterDraft:
+ type: object
+ required: [eventId, teams, reserve, version, confirmed]
+ properties:
+ eventId: { type: string }
+ teams: { type: array, minItems: 2, items: { $ref: "#/components/schemas/Team" } }
+ reserve: { type: array, items: { type: string } }
+ version: { type: integer }
+ confirmed: { type: boolean }
+ RosterSwapCommand:
+ type: object
+ required: [teamAId, playerAId, teamBId, playerBId, expectedVersion]
+ properties:
+ teamAId: { type: string }
+ playerAId: { type: string }
+ teamBId: { type: string }
+ playerBId: { type: string }
+ expectedVersion: { type: integer }
+ RosterSubstitutionCommand:
+ type: object
+ required: [teamId, outgoingPlayerId, reservePlayerId, expectedVersion]
+ properties:
+ teamId: { type: string }
+ outgoingPlayerId: { type: string }
+ reservePlayerId: { type: string }
+ expectedVersion: { type: integer }
+ SeriesBanCommand:
+ type: object
+ required: [teamId, expectedVersion]
+ properties:
+ teamId: { type: string }
+ map: { type: string }
+ hero: { type: string }
+ expectedVersion: { type: integer }
MapResultInput:
type: object
required: [mapName, outcome, expectedVersion]
@@ -722,10 +1042,12 @@ components:
type: integer
Series:
type: object
- required: [id, tournamentId, teamAId, teamBId, winnerTeamId, bestOf, results, version]
+ required: [id, eventId, tournamentId, teamAId, teamBId, winnerTeamId, bestOf, rulesetId, phase, playedMaps, maps, audit, results, version]
properties:
id:
type: string
+ eventId:
+ type: string
tournamentId:
type: string
teamAId:
@@ -736,6 +1058,19 @@ components:
type: string
bestOf:
type: integer
+ rulesetId: { type: string }
+ phase:
+ type: string
+ enum: [CoinTossPending, MapBan, MapPick, HeroBan, Playing, Completed]
+ coinToss: { type: [object, "null"] }
+ mapDraft: { type: [object, "null"] }
+ heroDraft: { type: [object, "null"] }
+ currentMap: { type: string }
+ playedMaps: { type: array, items: { type: string } }
+ nextMapPickerId: { type: string }
+ availableMaps: { type: array, items: { type: string } }
+ maps: { type: array, items: { type: object } }
+ audit: { type: array, items: { type: object } }
results:
type: array
items: