Add event management features to API, including event cancellation, deletion, registration closure, and workflow balance generation. Introduce new roster management endpoints and enhance participant handling with avoided player preferences. Update database schema and service logic to support these changes.
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit is contained in:
2026-07-19 02:10:02 +03:00
parent 59c0bdb345
commit 4b889fb8a0
30 changed files with 2766 additions and 149 deletions

View File

@@ -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)

View File

@@ -0,0 +1,243 @@
package httpapi
import (
"net/http"
"github.com/go-chi/chi/v5"
"mixmaker/backend/internal/domain"
)
func (s *Server) cancelEvent(w http.ResponseWriter, r *http.Request) {
var in struct {
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.CancelEvent(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) deleteEvent(w http.ResponseWriter, r *http.Request) {
err := s.service.DeleteEvent(r.Context(), who(r).account, chi.URLParam(r, "eventID"))
respond(w, nil, err, http.StatusNoContent)
}
func (s *Server) closeRegistration(w http.ResponseWriter, r *http.Request) {
var in struct {
RulesetID string `json:"rulesetId"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.CloseRegistration(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.RulesetID, in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) generateWorkflowBalance(w http.ResponseWriter, r *http.Request) {
var in struct {
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.GenerateBalance(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) getRoster(w http.ResponseWriter, r *http.Request) {
out, err := s.store.GetRoster(r.Context(), chi.URLParam(r, "eventID"))
respond(w, out, err, http.StatusOK)
}
func (s *Server) selectWorkflowBalance(w http.ResponseWriter, r *http.Request) {
var in struct {
Candidate domain.BalanceCandidate `json:"candidate"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.SelectWorkflowBalance(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.Candidate, in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) swapRoster(w http.ResponseWriter, r *http.Request) {
var in struct {
TeamAID string `json:"teamAId"`
PlayerAID string `json:"playerAId"`
TeamBID string `json:"teamBId"`
PlayerBID string `json:"playerBId"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.SwapRoster(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.TeamAID, in.PlayerAID, in.TeamBID, in.PlayerBID, in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) substituteRoster(w http.ResponseWriter, r *http.Request) {
s.handleSubstitute(w, r, false)
}
func (s *Server) emergencySubstitute(w http.ResponseWriter, r *http.Request) {
s.handleSubstitute(w, r, true)
}
func (s *Server) handleSubstitute(w http.ResponseWriter, r *http.Request, emergency bool) {
var in struct {
TeamID string `json:"teamId"`
OutgoingPlayerID string `json:"outgoingPlayerId"`
ReservePlayerID string `json:"reservePlayerId"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.SubstituteRoster(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.TeamID, in.OutgoingPlayerID, in.ReservePlayerID, in.ExpectedVersion, emergency)
respond(w, out, err, http.StatusOK)
}
func (s *Server) setRosterCaptain(w http.ResponseWriter, r *http.Request) {
var in struct {
TeamID string `json:"teamId"`
PlayerID string `json:"playerId"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.SetRosterCaptain(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.TeamID, in.PlayerID, in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) confirmRosters(w http.ResponseWriter, r *http.Request) {
var in struct {
ExpectedVersion int `json:"expectedVersion"`
ExpectedRosterVersion int `json:"expectedRosterVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.ConfirmRosters(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion, in.ExpectedRosterVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) startScrim(w http.ResponseWriter, r *http.Request) {
var in struct {
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.StartScrim(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion)
respond(w, out, err, http.StatusCreated)
}
func (s *Server) tossSeriesCoin(w http.ResponseWriter, r *http.Request) {
var in struct {
Seed string `json:"seed"`
ActingTeamID string `json:"actingTeamId"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
series, err := s.store.GetSeries(r.Context(), chi.URLParam(r, "id"))
if err == nil {
err = s.authorizeTeam(r, series.EventID, in.ActingTeamID)
}
if err != nil {
writeError(w, err)
return
}
out, err := s.service.TossSeriesCoin(r.Context(), who(r).account, series.ID, in.Seed, in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) banSeriesMap(w http.ResponseWriter, r *http.Request) {
var in struct {
TeamID string `json:"teamId"`
Map string `json:"map"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
series, err := s.store.GetSeries(r.Context(), chi.URLParam(r, "id"))
if err == nil {
err = s.authorizeTeam(r, series.EventID, in.TeamID)
}
if err != nil {
writeError(w, err)
return
}
out, err := s.service.BanSeriesMap(r.Context(), who(r).account, series.ID, in.TeamID, in.Map, in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) pickSeriesMap(w http.ResponseWriter, r *http.Request) {
var in struct {
TeamID string `json:"teamId"`
Map string `json:"map"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
series, err := s.store.GetSeries(r.Context(), chi.URLParam(r, "id"))
if err == nil {
err = s.authorizeTeam(r, series.EventID, in.TeamID)
}
if err != nil {
writeError(w, err)
return
}
out, err := s.service.PickSeriesMap(r.Context(), who(r).account, series.ID, in.TeamID, in.Map, in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) banSeriesHero(w http.ResponseWriter, r *http.Request) {
var in struct {
TeamID string `json:"teamId"`
Hero string `json:"hero"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
series, err := s.store.GetSeries(r.Context(), chi.URLParam(r, "id"))
if err == nil {
err = s.authorizeTeam(r, series.EventID, in.TeamID)
}
if err != nil {
writeError(w, err)
return
}
out, err := s.service.BanSeriesHero(r.Context(), who(r).account, series.ID, in.TeamID, in.Hero, in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) recordSeriesResult(w http.ResponseWriter, r *http.Request) {
var in struct {
Outcome domain.MapOutcome `json:"outcome"`
ActingTeamID string `json:"actingTeamId"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
series, err := s.store.GetSeries(r.Context(), chi.URLParam(r, "id"))
if err == nil {
err = s.authorizeTeam(r, series.EventID, in.ActingTeamID)
}
if err != nil {
writeError(w, err)
return
}
out, err := s.service.RecordSeriesResult(r.Context(), who(r).account, series.ID, in.Outcome, in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}

View File

@@ -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 {

View File

@@ -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 ""
}

View File

@@ -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
}

View File

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

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -0,0 +1,136 @@
package domain
import (
"fmt"
"slices"
)
type EventState string
const (
RegistrationOpen EventState = "RegistrationOpen"
RegistrationClosed EventState = "RegistrationClosed"
Balancing EventState = "Balancing"
RostersDraft EventState = "RostersDraft"
RostersConfirmed EventState = "RostersConfirmed"
Live EventState = "Live"
Completed EventState = "Completed"
Cancelled EventState = "Cancelled"
)
func (e *Event) Transition(from []EventState, to EventState, expectedVersion int) error {
if e.Version != expectedVersion {
return fmt.Errorf("%w: stale event version", ErrConflict)
}
if !slices.Contains(from, e.State) {
return fmt.Errorf("%w: event is %s", ErrConflict, e.State)
}
e.State = to
e.Version++
return nil
}
type RosterDraft struct {
EventID string `json:"eventId"`
Teams []Team `json:"teams"`
Reserve []string `json:"reserve"`
Version int `json:"version"`
Confirmed bool `json:"confirmed"`
}
func (r RosterDraft) Validate(requireCaptains bool) error {
if r.EventID == "" || len(r.Teams) < 2 {
return fmt.Errorf("%w: at least two teams are required", ErrInvalid)
}
seen := map[string]bool{}
for _, team := range r.Teams {
if len(team.Slots) != 5 {
return fmt.Errorf("%w: every team must contain five players", ErrInvalid)
}
counts := map[Role]int{}
for _, slot := range team.Slots {
if slot.PlayerID == "" || seen[slot.PlayerID] {
return fmt.Errorf("%w: roster players must be unique", ErrInvalid)
}
seen[slot.PlayerID] = true
counts[slot.Role]++
}
if counts[Tank] != 1 || counts[Damage] != 2 || counts[Support] != 2 {
return fmt.Errorf("%w: every team must use 1/2/2", ErrInvalid)
}
if requireCaptains && !slices.ContainsFunc(team.Slots, func(slot Slot) bool { return slot.PlayerID == team.CaptainPlayerID }) {
return fmt.Errorf("%w: every team needs a captain", ErrInvalid)
}
}
for _, playerID := range r.Reserve {
if playerID == "" || seen[playerID] {
return fmt.Errorf("%w: reserve players must be unique", ErrInvalid)
}
seen[playerID] = true
}
return nil
}
func (r *RosterDraft) Swap(teamAID, playerAID, teamBID, playerBID string) error {
if r.Confirmed {
return fmt.Errorf("%w: rosters are locked", ErrConflict)
}
var a, b *Slot
for teamIndex := range r.Teams {
for slotIndex := range r.Teams[teamIndex].Slots {
slot := &r.Teams[teamIndex].Slots[slotIndex]
if r.Teams[teamIndex].ID == teamAID && slot.PlayerID == playerAID {
a = slot
}
if r.Teams[teamIndex].ID == teamBID && slot.PlayerID == playerBID {
b = slot
}
}
}
if a == nil || b == nil {
return ErrNotFound
}
if a.Role != b.Role {
return fmt.Errorf("%w: only equal roles can be swapped", ErrInvalid)
}
a.PlayerID, b.PlayerID = b.PlayerID, a.PlayerID
a.Rating, b.Rating = b.Rating, a.Rating
r.Version++
return r.Validate(false)
}
func (r *RosterDraft) Substitute(teamID, outgoingID, reserveID string, rating int) error {
if r.Confirmed {
return fmt.Errorf("%w: rosters are locked", ErrConflict)
}
reserveIndex := slices.Index(r.Reserve, reserveID)
if reserveIndex < 0 {
return fmt.Errorf("%w: player is not in reserve", ErrInvalid)
}
for teamIndex := range r.Teams {
if r.Teams[teamIndex].ID != teamID {
continue
}
for slotIndex := range r.Teams[teamIndex].Slots {
slot := &r.Teams[teamIndex].Slots[slotIndex]
if slot.PlayerID == outgoingID {
slot.PlayerID, slot.Rating = reserveID, rating
r.Reserve[reserveIndex] = outgoingID
if r.Teams[teamIndex].CaptainPlayerID == outgoingID {
r.Teams[teamIndex].CaptainPlayerID = ""
}
r.Version++
return r.Validate(false)
}
}
}
return ErrNotFound
}
func (r *RosterDraft) EmergencySubstitute(teamID, outgoingID, reserveID string, rating int) error {
confirmed := r.Confirmed
r.Confirmed = false
err := r.Substitute(teamID, outgoingID, reserveID, rating)
r.Confirmed = confirmed
return err
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -0,0 +1,186 @@
package domain
import (
"fmt"
"slices"
"time"
)
type SeriesPhase string
const (
CoinTossPending SeriesPhase = "CoinTossPending"
MapBanPhase SeriesPhase = "MapBan"
MapPickPhase SeriesPhase = "MapPick"
HeroBanPhase SeriesPhase = "HeroBan"
PlayingPhase SeriesPhase = "Playing"
SeriesComplete SeriesPhase = "Completed"
)
type SeriesAction struct {
Kind string `json:"kind"`
Summary string `json:"summary"`
TeamID string `json:"teamId,omitempty"`
ActorAccountID string `json:"actorAccountId"`
At time.Time `json:"at"`
}
type SeriesMap struct {
Number int `json:"number"`
Name string `json:"name"`
HeroBans []DraftAction `json:"heroBans"`
Result *MapResult `json:"result,omitempty"`
}
func NewSeries(id, eventID, tournamentID string, teams [2]string, rules Ruleset) (*Series, error) {
if id == "" || eventID == "" || teams[0] == "" || teams[1] == "" || teams[0] == teams[1] {
return nil, fmt.Errorf("%w: invalid series", ErrInvalid)
}
if err := rules.Validate(); err != nil {
return nil, err
}
return &Series{
ID: id, EventID: eventID, TournamentID: tournamentID,
TeamAID: teams[0], TeamBID: teams[1], BestOf: rules.BestOf,
RulesetID: rules.ID, Phase: CoinTossPending,
SeriesBans: map[string][]string{},
}, nil
}
func (s *Series) Toss(seed, actor string, now time.Time, rules Ruleset) error {
if s.Phase != CoinTossPending {
return fmt.Errorf("%w: coin toss is unavailable", ErrConflict)
}
toss, err := TossCoin(s.TeamAID, s.TeamBID, seed, now)
if err != nil {
return err
}
s.CoinToss = &toss
first := toss.WinnerTeamID
if rules.InitialMapBanner == 1 {
first = s.otherTeam(first)
}
draft, err := NewMapDraft(s.availablePool(rules, 0), first, [2]string{s.TeamAID, s.TeamBID})
if err != nil {
return err
}
s.MapDraft, s.Phase = draft, MapBanPhase
s.Version++
s.Audit = append(s.Audit, SeriesAction{Kind: "coin_toss", Summary: "Coin toss won by " + toss.WinnerTeamID, TeamID: toss.WinnerTeamID, ActorAccountID: actor, At: now})
return nil
}
func (s *Series) BanMap(teamID, name, actor string, now time.Time, rules Ruleset) error {
if s.Phase != MapBanPhase || s.MapDraft == nil {
return fmt.Errorf("%w: map ban is unavailable", ErrConflict)
}
if err := s.MapDraft.Ban(teamID, name, actor, now); err != nil {
return err
}
s.Version++
s.Audit = append(s.Audit, SeriesAction{Kind: "map_ban", Summary: name + " banned", TeamID: teamID, ActorAccountID: actor, At: now})
selected, complete := s.MapDraft.Selected()
if !complete {
return nil
}
return s.startHeroDraft(selected, actor, now, rules)
}
func (s *Series) PickMap(teamID, name, actor string, now time.Time, rules Ruleset) error {
if s.Phase != MapPickPhase {
return fmt.Errorf("%w: map pick is unavailable", ErrConflict)
}
if teamID != s.NextMapPickerID {
return fmt.Errorf("%w: only the previous map loser can pick", ErrConflict)
}
if !slices.Contains(s.AvailableMaps, name) {
return fmt.Errorf("%w: map unavailable", ErrInvalid)
}
s.Version++
s.Audit = append(s.Audit, SeriesAction{Kind: "map_selected", Summary: name + " picked", TeamID: teamID, ActorAccountID: actor, At: now})
return s.startHeroDraft(name, actor, now, rules)
}
func (s *Series) startHeroDraft(selected, actor string, now time.Time, rules Ruleset) error {
s.CurrentMap = selected
first := s.otherTeam(s.CoinToss.WinnerTeamID)
if rules.InitialHeroBanner == 0 {
first = s.CoinToss.WinnerTeamID
}
draft, err := NewHeroDraft(rules.Heroes, [2]string{s.TeamAID, s.TeamBID}, first, rules.HeroBansPerTeam, s.SeriesBans)
if err != nil {
return err
}
s.HeroDraft, s.Phase = draft, HeroBanPhase
s.NextMapPickerID, s.AvailableMaps = "", nil
s.Maps = append(s.Maps, SeriesMap{Number: len(s.Maps) + 1, Name: selected})
if len(s.Audit) == 0 || s.Audit[len(s.Audit)-1].Kind != "map_selected" {
s.Audit = append(s.Audit, SeriesAction{Kind: "map_selected", Summary: selected + " selected", ActorAccountID: actor, At: now})
}
return nil
}
func (s *Series) BanHero(teamID, hero, actor string, now time.Time) error {
if s.Phase != HeroBanPhase || s.HeroDraft == nil {
return fmt.Errorf("%w: hero ban is unavailable", ErrConflict)
}
if err := s.HeroDraft.Ban(teamID, hero, actor, now); err != nil {
return err
}
s.Version++
s.Maps[len(s.Maps)-1].HeroBans = slices.Clone(s.HeroDraft.CurrentBans)
s.Audit = append(s.Audit, SeriesAction{Kind: "hero_ban", Summary: hero + " banned", TeamID: teamID, ActorAccountID: actor, At: now})
if s.HeroDraft.Complete() {
s.SeriesBans = s.HeroDraft.SeriesBans
s.Phase = PlayingPhase
}
return nil
}
func (s *Series) RecordCurrentMap(outcome MapOutcome, actor string, now time.Time, rules Ruleset) error {
if s.Phase != PlayingPhase || s.CurrentMap == "" {
return fmt.Errorf("%w: map result is unavailable", ErrConflict)
}
result := MapResult{MapName: s.CurrentMap, Outcome: outcome, ActorAccountID: actor, RecordedAt: now}
if err := s.RecordResult(result); err != nil {
return err
}
s.PlayedMaps = append(s.PlayedMaps, s.CurrentMap)
s.Maps[len(s.Maps)-1].Result = &result
s.Audit = append(s.Audit, SeriesAction{Kind: "result", Summary: s.CurrentMap + ": " + string(outcome), ActorAccountID: actor, At: now})
if s.WinnerTeamID != "" {
s.Phase = SeriesComplete
return nil
}
poolIndex := len(s.Maps)
if poolIndex >= len(rules.MapPools) {
poolIndex = len(rules.MapPools) - 1
}
switch outcome {
case TeamAWin:
s.NextMapPickerID = s.TeamBID
case TeamBWin:
s.NextMapPickerID = s.TeamAID
default:
// A draw has no loser, so the initial coin-toss loser receives the pick.
s.NextMapPickerID = s.otherTeam(s.CoinToss.WinnerTeamID)
}
s.AvailableMaps = s.availablePool(rules, poolIndex)
if len(s.AvailableMaps) == 0 {
return fmt.Errorf("%w: no unplayed maps remain in pool", ErrInvalid)
}
s.MapDraft, s.HeroDraft, s.CurrentMap, s.Phase = nil, nil, "", MapPickPhase
return nil
}
func (s Series) availablePool(rules Ruleset, index int) []string {
pool := slices.Clone(rules.MapPools[index])
return slices.DeleteFunc(pool, func(name string) bool { return slices.Contains(s.PlayedMaps, name) })
}
func (s Series) otherTeam(teamID string) string {
if teamID == s.TeamAID {
return s.TeamBID
}
return s.TeamAID
}

View File

@@ -0,0 +1,127 @@
package domain
import (
"errors"
"fmt"
"testing"
"time"
)
func TestEventWorkflowRejectsStaleVersion(t *testing.T) {
event := Event{State: RegistrationOpen, Version: 2}
if err := event.Transition([]EventState{RegistrationOpen}, RegistrationClosed, 1); !errors.Is(err, ErrConflict) {
t.Fatalf("expected stale version conflict, got %v", err)
}
if event.State != RegistrationOpen || event.Version != 2 {
t.Fatal("stale transition mutated the event")
}
}
func TestRosterSwapAndReserveKeepRoleShape(t *testing.T) {
roster := testRoster()
if err := roster.Swap("a", "a-d1", "b", "b-s1"); !errors.Is(err, ErrInvalid) {
t.Fatalf("expected cross-role swap rejection, got %v", err)
}
if err := roster.Swap("a", "a-d1", "b", "b-d1"); err != nil {
t.Fatalf("same-role swap failed: %v", err)
}
if err := roster.Substitute("a", "b-d1", "reserve", 30); err != nil {
t.Fatalf("reserve substitution failed: %v", err)
}
if roster.Reserve[0] != "b-d1" {
t.Fatalf("displaced player was not returned to reserve: %#v", roster.Reserve)
}
if err := roster.Validate(false); err != nil {
t.Fatalf("valid 1/2/2 roster rejected: %v", err)
}
}
func TestSeriesFSMRunsCoinBansAndResult(t *testing.T) {
rules := testRules()
series, err := NewSeries("series", "event", "", [2]string{"a", "b"}, rules)
if err != nil {
t.Fatal(err)
}
now := time.Unix(100, 0).UTC()
if err = series.Toss("seed", "admin", now, rules); err != nil {
t.Fatal(err)
}
for !func() bool { _, selected := series.MapDraft.Selected(); return selected }() {
name := firstAvailableMap(series.MapDraft)
if err = series.BanMap(series.MapDraft.NextTeam(), name, "admin", now, rules); err != nil {
t.Fatal(err)
}
}
if series.Phase != HeroBanPhase {
t.Fatalf("expected hero bans, got %s", series.Phase)
}
for _, hero := range []string{"Tank A", "Tank B", "Damage A", "Damage B"} {
if err = series.BanHero(series.HeroDraft.NextTeam(), hero, "admin", now); err != nil {
t.Fatal(err)
}
}
if series.Phase != PlayingPhase {
t.Fatalf("expected playing phase, got %s", series.Phase)
}
if err = series.RecordCurrentMap(TeamAWin, "admin", now, rules); err != nil {
t.Fatal(err)
}
if series.Phase != MapPickPhase || len(series.PlayedMaps) != 1 || series.NextMapPickerID != "b" {
t.Fatalf("next map was not prepared: phase=%s maps=%v", series.Phase, series.PlayedMaps)
}
if contains(series.AvailableMaps, series.PlayedMaps[0]) {
t.Fatal("played map remained available in a later pool")
}
nextMap := series.AvailableMaps[0]
if err = series.PickMap("a", nextMap, "admin", now, rules); !errors.Is(err, ErrConflict) {
t.Fatalf("winner was allowed to pick the next map: %v", err)
}
if err = series.PickMap("b", nextMap, "admin", now, rules); err != nil || series.Phase != HeroBanPhase {
t.Fatalf("loser could not pick the next map: phase=%s err=%v", series.Phase, err)
}
}
func testRoster() RosterDraft {
teams := make([]Team, 2)
for teamIndex, id := range []string{"a", "b"} {
slots := []Slot{{PlayerID: id + "-t", Role: Tank, Rating: 20}}
for i := 1; i <= 2; i++ {
slots = append(slots,
Slot{PlayerID: fmt.Sprintf("%s-d%d", id, i), Role: Damage, Rating: 20},
Slot{PlayerID: fmt.Sprintf("%s-s%d", id, i), Role: Support, Rating: 20},
)
}
teams[teamIndex] = Team{ID: id, EventID: "event", Name: id, Slots: slots}
}
return RosterDraft{EventID: "event", Teams: teams, Reserve: []string{"reserve"}}
}
func testRules() Ruleset {
return Ruleset{
ID: "rules", BestOf: 3, HeroBansPerTeam: 2,
MapPools: [][]string{{"Map 1", "Map 2", "Map 3"}, {"Map 1", "Map 4", "Map 5"}, {"Map 1", "Map 2", "Map 6"}},
Heroes: []Hero{
{Name: "Tank A", Role: Tank}, {Name: "Tank B", Role: Tank},
{Name: "Damage A", Role: Damage}, {Name: "Damage B", Role: Damage},
{Name: "Support A", Role: Support}, {Name: "Support B", Role: Support},
},
}
}
func firstAvailableMap(draft *MapDraft) string {
for _, name := range draft.Pool {
if !contains(draft.Banned, name) {
return name
}
}
return ""
}
func contains(values []string, value string) bool {
for _, item := range values {
if item == value {
return true
}
}
return false
}

View File

@@ -0,0 +1,27 @@
package realtime
import (
"testing"
"time"
)
func TestHubPublishesToTopicAndWildcardSubscribers(t *testing.T) {
hub := New()
topic, cancelTopic := hub.Subscribe("series:s1")
defer cancelTopic()
all, cancelAll := hub.Subscribe("*")
defer cancelAll()
hub.Publish("series:s1", map[string]int{"version": 2})
for name, channel := range map[string]<-chan Event{"topic": topic, "wildcard": all} {
select {
case event := <-channel:
if event.Topic != "series:s1" || string(event.Data) != `{"version":2}` {
t.Fatalf("%s subscriber received %#v", name, event)
}
case <-time.After(time.Second):
t.Fatalf("%s subscriber did not receive update", name)
}
}
}

View File

@@ -0,0 +1,30 @@
-- +mixmaker Up
ALTER TABLE events
ADD COLUMN IF NOT EXISTS state text NOT NULL DEFAULT 'RegistrationOpen',
ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS ruleset_id text NOT NULL DEFAULT 'standard-control-hybrid-control',
ADD COLUMN IF NOT EXISTS active_series_id text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS tournament_id text NOT NULL DEFAULT '';
ALTER TABLE events
DROP CONSTRAINT IF EXISTS events_state_check,
ADD CONSTRAINT events_state_check CHECK (state IN (
'RegistrationOpen', 'RegistrationClosed', 'Balancing',
'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'
));
CREATE TABLE IF NOT EXISTS event_rosters (
event_id text PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE,
body jsonb NOT NULL,
version integer NOT NULL DEFAULT 0,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- +mixmaker Down
DROP TABLE IF EXISTS event_rosters;
ALTER TABLE events
DROP COLUMN IF EXISTS tournament_id,
DROP COLUMN IF EXISTS active_series_id,
DROP COLUMN IF EXISTS ruleset_id,
DROP COLUMN IF EXISTS version,
DROP COLUMN IF EXISTS state;

View File

@@ -0,0 +1,7 @@
-- +mixmaker Up
ALTER TABLE players
ADD COLUMN IF NOT EXISTS avoided_player_ids jsonb NOT NULL DEFAULT '[]'::jsonb;
-- +mixmaker Down
ALTER TABLE players
DROP COLUMN IF EXISTS avoided_player_ids;

View File

@@ -0,0 +1,14 @@
-- +mixmaker Up
ALTER TABLE events DROP CONSTRAINT IF EXISTS events_state_check;
ALTER TABLE events ADD CONSTRAINT events_state_check CHECK (state IN (
'RegistrationOpen', 'RegistrationClosed', 'Balancing',
'RostersDraft', 'RostersConfirmed', 'Live', 'Completed', 'Cancelled'
));
-- +mixmaker Down
UPDATE events SET state = 'Completed' WHERE state = 'Cancelled';
ALTER TABLE events DROP CONSTRAINT IF EXISTS events_state_check;
ALTER TABLE events ADD CONSTRAINT events_state_check CHECK (state IN (
'RegistrationOpen', 'RegistrationClosed', 'Balancing',
'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'
));

View File

@@ -13,6 +13,8 @@ describe('Mixmaker frontend', () => {
render(<App />)
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(<App />)
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(<App />)
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(<App />)
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()
})
})

View File

@@ -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() {
<header className="topbar">
<Brand />
<nav className="desktop-nav" aria-label="Primary navigation">{navItems.map(({ icon: Icon, ...item }) => <Link key={item.label} {...item} activeProps={{ className: 'active' }}><Icon />{item.label}</Link>)}</nav>
<div className="account"><LanguageSelector /><span className="live-dot" title="Realtime connected" /><Link to="/profile" className="avatar" aria-label="Open profile">{initials}</Link><button className="icon-button" onClick={() => logout.mutate()} aria-label="Sign out" title="Sign out"><LogOut /></button><button className="icon-button mobile-menu" onClick={() => setOpen(!open)} aria-label="Toggle navigation" aria-expanded={open}>{open ? <X /> : <Menu />}</button></div>
<div className="account"><LanguageSelector /><span className={`live-dot ${realtimeStatus}`} title={realtimeStatus === 'connected' ? 'Realtime connected' : 'Realtime reconnecting'} /><Link to="/profile" className="avatar" aria-label="Open profile">{initials}</Link><button className="icon-button" onClick={() => logout.mutate()} aria-label="Sign out" title="Sign out"><LogOut /></button><button className="icon-button mobile-menu" onClick={() => setOpen(!open)} aria-label="Toggle navigation" aria-expanded={open}>{open ? <X /> : <Menu />}</button></div>
</header>
{open && <nav className="mobile-nav" aria-label="Mobile navigation">{[...navItems, { to: '/profile' as const, label: 'Profile', icon: UserRound }].map(({ icon: Icon, ...item }) => <Link key={item.label} {...item} onClick={() => setOpen(false)}><Icon />{item.label}</Link>)}</nav>}
<main><Outlet /></main>
@@ -95,7 +121,7 @@ function LoginPage() {
function PageHeader({ eyebrow, title, description, actions }: { eyebrow: string; title: string; description?: string; actions?: ReactNode }) {
return <header className="page-header"><div><span className="eyebrow">{eyebrow}</span><h1>{title}</h1>{description && <p>{description}</p>}</div>{actions && <div className="page-actions">{actions}</div>}</header>
}
function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'success' | 'warning' | 'live' | 'neutral' | 'blue' }) { return <span className={`badge badge-${tone}`}>{children}</span> }
function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'success' | 'warning' | 'live' | 'danger' | 'neutral' | 'blue' }) { return <span className={`badge badge-${tone}`}>{children}</span> }
function LoadingState({ label = 'Loading match data…' }: { label?: string }) { return <div className="state-card" role="status"><RefreshCw className="spin" /><h2>{label}</h2><p>Syncing with the server.</p></div> }
function ErrorState({ retry }: { retry?: () => void }) { return <div className="state-card" role="alert"><CircleHelp /><h2>Couldnt load this view</h2><p>Your data is safe. Check your connection and try again.</p>{retry && <button className="button secondary" onClick={retry}>Try again</button>}</div> }
@@ -110,13 +136,17 @@ function EventsPage() {
<PageHeader eyebrow="Scrim schedule" title="Your events" description="Join a mix, check the roster, and get match-ready." actions={<div className="segmented"><button className={filter === 'upcoming' ? 'active' : ''} onClick={() => setFilter('upcoming')}>Upcoming</button><button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>All</button></div>} />
{demoMode && <section className="featured-event"><div><Badge tone="live"><Radio />Live now</Badge><h2>{demoEvents[2].title}</h2><p>Semifinal 1 · Map 2 draft in progress</p></div><div className="featured-score"><span>EMBER</span><strong>1<i>:</i>0</strong><span>AZURE</span></div><Link className="button primary" to="/watch/$seriesId" params={{ seriesId: 'series-1' }}>Watch live <ChevronRight /></Link></section>}
<div className="section-title"><div><h2>Coming up</h2><p>{visible.length} scheduled events</p></div><button className="icon-button" aria-label="Filter events"><ListFilter /></button></div>
{visible.length === 0 ? <div className="state-card"><CalendarDays /><h2>No events yet</h2><p>New community nights will appear here.</p></div> : <div className="event-grid">{visible.filter((e) => e.status !== 'live').map((event) => <EventCard key={event.id} event={event} />)}</div>}
{visible.length === 0 ? <div className="state-card"><CalendarDays /><h2>No events yet</h2><p>New community nights will appear here.</p></div> : <div className="event-grid">{visible.map((event) => <EventCard key={event.id} event={event} />)}</div>}
</div>
}
function EventCard({ event }: { event: MixEvent }) {
const date = new Date(event.startsAt)
return <article className="card event-card"><div className="date-block"><strong>{date.toLocaleDateString(undefined, { day: '2-digit' })}</strong><span>{date.toLocaleDateString(undefined, { month: 'short' }).toUpperCase()}</span></div><div className="event-card-body"><div className="event-meta"><Badge tone={event.myRsvp === 'going' ? 'success' : 'warning'}>{event.myRsvp === 'going' ? <Check /> : <Clock3 />}{event.myRsvp === 'going' ? 'Going' : 'Maybe'}</Badge><span>{date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span></div><h3>{event.title}</h3><p>{event.description}</p><div className="event-footer"><span><UsersRound />{event.counts.going} going</span><Link to="/events/$eventId" params={{ eventId: event.id }}>View event <ChevronRight /></Link></div></div></article>
const statusBadge = event.status === 'cancelled' ? <Badge tone="danger"><X />Cancelled</Badge>
: event.status === 'completed' ? <Badge tone="success"><Check />Completed</Badge>
: event.status === 'live' ? <Badge tone="live"><Radio />LIVE</Badge>
: <Badge tone={event.myRsvp === 'going' ? 'success' : 'warning'}>{event.myRsvp === 'going' ? <Check /> : <Clock3 />}{event.myRsvp === 'going' ? 'Going' : 'Maybe'}</Badge>
return <Link className={`card event-card status-${event.status}`} to="/events/$eventId" params={{ eventId: event.id }} aria-label={`Open ${event.title}`}><div className="date-block"><strong>{date.toLocaleDateString(undefined, { day: '2-digit' })}</strong><span>{date.toLocaleDateString(undefined, { month: 'short' }).toUpperCase()}</span></div><div className="event-card-body"><div className="event-meta">{statusBadge}<span>{date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span></div><h3>{event.title}</h3><p>{event.description}</p><div className="event-footer"><span><UsersRound />{event.counts.going} going</span><span className="event-open">View event <ChevronRight /></span></div></div></Link>
}
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<RsvpStatus, string> = { going: 'Going', maybe: 'Maybe', not_going: 'Cant go' }
if (eventQuery.isLoading || registrations.isLoading || session.isLoading) return <LoadingState label="Loading event…" />
if (eventQuery.isError || registrations.isError || session.isError || !eventQuery.data || !session.data) return <ErrorState retry={() => { 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<RsvpStatus, number> = { 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 <div className="page"><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<Badge tone="success">Registration open</Badge>} /><div className="detail-layout">
<section className="card event-hero"><div className="event-time"><CalendarDays /><div><span>Starts</span><strong>{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="event-time"><Clock3 /><div><span>Registration closes</span><strong>{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="divider" /><h2>Are you playing?</h2><p>Your latest response is sent to the organizer.</p><div className="rsvp-control" role="group" aria-label="RSVP status">{(Object.keys(labels) as RsvpStatus[]).map((status) => <button key={status} disabled={rsvpMutation.isPending} className={rsvp === status ? `active ${status}` : ''} aria-pressed={rsvp === status} onClick={() => rsvpMutation.mutate(status)}>{status === 'going' ? <Check /> : status === 'maybe' ? <CircleHelp /> : <X />}{labels[status]}</button>)}</div>{rsvpMutation.isSuccess && <div className="save-note"><Check />Response saved · Server confirmed just now</div>}{rsvpMutation.isError && <p className="error-note">{rsvpMutation.error.message}</p>}</section>
<aside className="card roster-preview"><div className="section-title"><div><h2>Attendance</h2><p>{Object.values(counts).reduce((a, b) => a + b, 0)} responses</p></div><UsersRound /></div><div className="attendance-bar"><span /><span /><span /></div><div className="attendance-stats"><span><i className="green" />Going <strong>{counts.going}</strong></span><span><i className="yellow" />Maybe <strong>{counts.maybe}</strong></span><span><i />No <strong>{counts.not_going}</strong></span></div>{session.data.account.role === 'admin' && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside>
return <div className={`page event-detail status-${event.status}`}><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<>{event.status !== 'cancelled' && event.activeSeriesId && <Link className="button primary" to="/series/$seriesId" params={{ seriesId: event.activeSeriesId }}>Open live series</Link>}{event.status !== 'cancelled' && event.tournamentId && <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}<Badge tone={event.workflowState === 'Cancelled' ? 'danger' : event.workflowState === 'Live' ? 'live' : event.workflowState === 'Completed' || event.workflowState === 'RegistrationOpen' ? 'success' : 'neutral'}>{event.workflowState.replace(/([a-z])([A-Z])/g, '$1 $2')}</Badge></>} /><div className="detail-layout">
<section className="card event-hero"><div className="event-time"><CalendarDays /><div><span>Starts</span><strong>{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="event-time"><Clock3 /><div><span>Registration closes</span><strong>{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="divider" /><h2>Are you playing?</h2><p>{event.workflowState === 'RegistrationOpen' ? 'Your latest response is sent to the organizer.' : 'Registration has been closed by the organizer.'}</p><div className="rsvp-control" role="group" aria-label="RSVP status">{(Object.keys(labels) as RsvpStatus[]).map((status) => <button key={status} disabled={rsvpMutation.isPending || event.workflowState !== 'RegistrationOpen'} className={rsvp === status ? `active ${status}` : ''} aria-pressed={rsvp === status} onClick={() => rsvpMutation.mutate(status)}>{status === 'going' ? <Check /> : status === 'maybe' ? <CircleHelp /> : <X />}{labels[status]}</button>)}</div>{rsvpMutation.isSuccess && <div className="save-note"><Check />Response saved · Server confirmed just now</div>}{rsvpMutation.isError && <p className="error-note">{rsvpMutation.error.message}</p>}</section>
<aside className="card roster-preview"><div className="section-title"><div><h2>Attendance</h2><p>{attendanceTotal} responses</p></div><UsersRound /></div><div className="attendance-bar">{attendanceTotal > 0 && <>{counts.going > 0 && <span className="going" style={{ flexGrow: counts.going, width: 0 }} />}{counts.maybe > 0 && <span className="maybe" style={{ flexGrow: counts.maybe, width: 0 }} />}{counts.not_going > 0 && <span className="not-going" style={{ flexGrow: counts.not_going, width: 0 }} />}</>}</div><div className="attendance-stats"><span><i className="green" />Going <strong>{counts.going}</strong></span><span><i className="yellow" />Maybe <strong>{counts.maybe}</strong></span><span><i />No <strong>{counts.not_going}</strong></span></div>{session.data.account.role === 'admin' && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside>
</div><section className="card public-roster"><div className="section-title"><div><h2>Registered players</h2><p>Everyone can see who responded and their current status.</p></div><UsersRound /></div>{attendees.length === 0 ? <p className="empty-note">No responses yet.</p> : <div className="public-roster-grid">{attendees.map((registration) => <div className="public-roster-player" key={registration.id}><span className="mini-avatar">{registration.player.displayName.slice(0, 2).toUpperCase()}</span><strong>{registration.player.displayName}</strong><Badge tone={registration.status === 'going' ? 'success' : registration.status === 'maybe' ? 'warning' : 'neutral'}>{labels[registration.status]}</Badge></div>)}</div>}</section></div>
}
function FAQPage() {
return <div className="page faq-page"><PageHeader eyebrow="Rules and help" title="Frequently asked questions" description="Everything players and captains need before the scrim starts." /><div className="faq-grid">
<section className="card faq-card"><span>01</span><div><h2>How are teams balanced?</h2><p>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.</p></div></section>
<section className="card faq-card"><span>02</span><div><h2>How does registration work?</h2><p>Choose Going, Maybe, or Cant go before registration closes. Only Going players enter balancing. After an admin closes registration, normal RSVP changes are locked.</p></div></section>
<section className="card faq-card"><span>03</span><div><h2>What happens before the scrim?</h2><p>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.</p></div></section>
<section className="card faq-card"><span>04</span><div><h2>How is the first map selected?</h2><p>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.</p></div></section>
<section className="card faq-card"><span>05</span><div><h2>Who picks later maps?</h2><p>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.</p></div></section>
<section className="card faq-card"><span>06</span><div><h2>How do hero bans work?</h2><p>Before every map, teams make two bans each in alternating order. The initial coin-toss loser bans first. A teams 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.</p></div></section>
<section className="card faq-card"><span>07</span><div><h2>How does Bo3 scoring work?</h2><p>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.</p></div></section>
<section className="card faq-card"><span>08</span><div><h2>What can captains do?</h2><p>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.</p></div></section>
</div></div>
}
function ProfilePage() {
const client = useQueryClient()
const language = useLanguage()
@@ -161,15 +201,24 @@ function ProfilePage() {
const [ratings, setRatings] = useState<Record<PlayerRole, RankOrdinal>>({ tank: 20, damage: 20, support: 20 })
const [preferredRoles, setPreferredRoles] = useState<PlayerRole[]>([])
const [preferredPlayerIds, setPreferredPlayerIds] = useState<string[]>([])
const [avoidedPlayerIds, setAvoidedPlayerIds] = useState<string[]>([])
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 <LoadingState label="Loading profile…" />
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 <div className="page narrow"><PageHeader eyebrow="Player profile" title="Set your competitive ranks" description="Your role ranks help the server build fair, role-locked teams." /><section className="profile-banner card"><div className="large-avatar">{profile.data.displayName.slice(0, 2).toUpperCase()}</div><div><h2>{profile.data.displayName}</h2><p>{profile.data.battleTag}</p><Badge tone="blue"><Gamepad2 />Connected via Discord</Badge></div></section><form className="card ratings-form" onSubmit={submit}><div className="section-title"><div><h2>Competitive ranks</h2><p>Select your current Overwatch rank for each role.</p></div><span>Updated {new Date(profile.data.ratings.updatedAt).toLocaleDateString()}</span></div>{roles.map((role) => <label className={`rating-row role-${role.key}`} key={role.key}><div><span className="role-icon">{role.key === 'tank' ? <Shield /> : role.key === 'damage' ? <Zap /> : <Sparkles />}</span><span><strong>{role.label}</strong><small>{role.hint}</small></span></div><select id={`rating-${role.key}`} className="rank-select" value={ratings[role.key]} onChange={(e) => setRatings({ ...ratings, [role.key]: toRankOrdinal(Number(e.target.value)) })}>{options.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>)}<section className="preference-section"><div><h2>Preferred roles</h2><p>Choose every role you enjoy playing.</p></div><div className="preference-chips">{roles.map((role) => <button type="button" aria-pressed={preferredRoles.includes(role.key)} className={preferredRoles.includes(role.key) ? 'active' : ''} key={role.key} onClick={() => setPreferredRoles((current) => current.includes(role.key) ? current.filter((item) => item !== role.key) : [...current, role.key])}>{role.label}</button>)}</div></section><section className="preference-section"><div><h2>Preferred teammates</h2><p>Choose up to 3 players. The balancer treats these as preferences, not guarantees.</p></div><div className="teammate-grid">{players.data.filter((player) => player.id !== profile.data.id).map((player) => { const selected = preferredPlayerIds.includes(player.id); const disabled = !selected && preferredPlayerIds.length >= 3; return <label className={selected ? 'selected' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setPreferredPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><div className="form-footer"><p><Shield />Only you can edit your ranks and preferences.{update.isError && ` ${update.error.message}`}</p><button className="button primary" type="submit" disabled={update.isPending}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save profile'}</button></div></form></div>
const eligiblePlayers = players.data.filter((player) => player.id !== profile.data.id && Boolean(player.accountId))
return <div className="page narrow"><PageHeader eyebrow="Player profile" title="Set your competitive ranks" description="Your role ranks help the server build fair, role-locked teams." /><section className="profile-banner card"><div className="large-avatar">{profile.data.displayName.slice(0, 2).toUpperCase()}</div><div><h2>{profile.data.displayName}</h2><p>{profile.data.battleTag}</p><Badge tone="blue"><Gamepad2 />Connected via Discord</Badge></div></section><form className="card ratings-form" onSubmit={submit}><div className="section-title"><div><h2>Competitive ranks</h2><p>Select your current Overwatch rank for each role.</p></div><span>Updated {new Date(profile.data.ratings.updatedAt).toLocaleDateString()}</span></div>{roles.map((role) => <label className={`rating-row role-${role.key}`} key={role.key}><div><span className="role-icon">{role.key === 'tank' ? <Shield /> : role.key === 'damage' ? <Zap /> : <Sparkles />}</span><span><strong>{role.label}</strong><small>{role.hint}</small></span></div><select id={`rating-${role.key}`} className="rank-select" value={ratings[role.key]} onChange={(e) => setRatings({ ...ratings, [role.key]: toRankOrdinal(Number(e.target.value)) })}>{options.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>)}<section className="preference-section"><div><h2>Preferred roles</h2><p>Choose every role you enjoy playing.</p></div><div className="preference-chips">{roles.map((role) => <button type="button" aria-pressed={preferredRoles.includes(role.key)} className={preferredRoles.includes(role.key) ? 'active' : ''} key={role.key} onClick={() => setPreferredRoles((current) => current.includes(role.key) ? current.filter((item) => item !== role.key) : [...current, role.key])}>{role.label}</button>)}</div></section><section className="preference-section"><div><h2>Preferred teammates</h2><p>Choose up to 3 players. The balancer treats these as preferences, not guarantees.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !avoidedPlayerIds.includes(player.id)).map((player) => { const selected = preferredPlayerIds.includes(player.id); const disabled = !selected && preferredPlayerIds.length >= 3; return <label className={selected ? 'selected' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setPreferredPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><section className="preference-section avoid-section"><div><h2>Avoided teammates</h2><p>Choose up to 3 players. This is a soft preference, so balance can still place you together when necessary.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !preferredPlayerIds.includes(player.id)).map((player) => { const selected = avoidedPlayerIds.includes(player.id); const disabled = !selected && avoidedPlayerIds.length >= 3; return <label className={selected ? 'selected avoided' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setAvoidedPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><div className="form-footer"><p><Shield />Only you can edit your ranks and preferences.{update.isError && ` ${update.error.message}`}</p><button className="button primary" type="submit" disabled={update.isPending}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save profile'}</button></div></form></div>
}
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 <LoadingState label="Loading admin control room…" />
if (events.isError) return <ErrorState retry={() => void events.refetch()} />
return <div className="page"><PageHeader eyebrow="Admin control room" title={createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={<>{events.data && events.data.length > 0 && <select aria-label="Selected event" value={selected?.id ?? ''} onChange={(e) => { setSelectedId(e.target.value); setCreateMode(false) }}>{events.data.map((event) => <option value={event.id} key={event.id}>{event.title}</option>)}</select>}<button className="button secondary" onClick={() => { setCreateMode(true); setTab('events') }}><Sparkles />New event</button></>} /><div className="tabs" role="tablist">{(['events', 'participants', 'balance'] as AdminTab[]).map((item) => <button key={item} role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} disabled={!selected && item !== 'events'} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : 'Balance teams'}</button>)}</div>{tab === 'events' && <EventSetup event={createMode ? undefined : selected} onCreated={(event) => { setCreateMode(false); setSelectedId(event.id) }} />}{tab === 'participants' && selected && <Participants eventId={selected.id} />}{tab === 'balance' && selected && <BalanceView eventId={selected.id} />}</div>
return <div className="page"><PageHeader eyebrow="Admin control room" title={createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={<>{events.data && events.data.length > 0 && <select aria-label="Selected event" value={selected?.id ?? ''} onChange={(e) => { setSelectedId(e.target.value); setCreateMode(false) }}>{events.data.map((event) => <option value={event.id} key={event.id}>{event.title}</option>)}</select>}<button className="button secondary" onClick={() => { setCreateMode(true); setTab('events') }}><Sparkles />New event</button></>} /><div className="tabs" role="tablist">{(['events', 'participants', 'balance'] as AdminTab[]).map((item) => <button key={item} role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} disabled={!selected && item !== 'events'} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : 'Balance teams'}</button>)}</div>{tab === 'events' && <EventSetup event={createMode ? undefined : selected} onCreated={(event) => { setCreateMode(false); setSelectedId(event.id) }} onDeleted={() => { setSelectedId(undefined); setCreateMode(false) }} />}{tab === 'participants' && selected && <Participants eventId={selected.id} />}{tab === 'balance' && selected && <BalanceView eventId={selected.id} />}</div>
}
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<EventInput>(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<MixEvent[]>(['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<MixEvent[]>(['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 <div className="admin-grid"><form className="card settings-card" onSubmit={(e) => { e.preventDefault(); save.mutate() }}><div className="section-title"><div><h2>{event ? 'Event settings' : 'New event'}</h2><p>Scheduling and registration</p></div>{event && <Badge tone="success">Published</Badge>}</div><div className="field-grid"><label>Event name<input required value={form.name} onChange={(e) => set('name', e.target.value)} /></label><label>Start time<input required type="datetime-local" value={form.startsAt} onChange={(e) => set('startsAt', e.target.value)} /></label><label>End time<input required type="datetime-local" value={form.endsAt} onChange={(e) => set('endsAt', e.target.value)} /></label><label>Registration deadline<input required type="datetime-local" value={form.registrationDeadline} onChange={(e) => set('registrationDeadline', e.target.value)} /></label></div><label>Description<textarea value={form.description} onChange={(e) => set('description', e.target.value)} /></label><div className="form-footer"><p>{save.isError ? save.error.message : 'Times are sent to the server in UTC.'}</p><button className="button primary" disabled={save.isPending}>{save.isPending ? 'Saving…' : event ? 'Save changes' : 'Create event'}</button></div></form><aside className="card admin-summary"><h2>Server authority</h2><div className="readiness-score"><Shield /><span>Validation runs on the backend</span></div><ul><li><Check />UTC scheduling</li><li><Check />Audited admin changes</li><li><Check />Registration deadline rules</li></ul></aside></div>
return <div className="admin-grid">
<form className="card settings-card" onSubmit={(e) => { e.preventDefault(); save.mutate() }}>
<div className="section-title"><div><h2>{event ? 'Event settings' : 'New event'}</h2><p>Scheduling and registration</p></div>{event && <div className="event-admin-actions"><Badge tone={event.status === 'cancelled' ? 'danger' : event.status === 'live' ? 'live' : 'success'}>{event.status === 'cancelled' ? 'Cancelled' : event.status === 'completed' ? 'Completed' : event.status === 'live' ? 'LIVE' : 'Published'}</Badge><button className="button danger-outline" type="button" disabled={cancel.isPending || event.status === 'cancelled' || event.status === 'completed'} onClick={() => cancel.mutate()}>Cancel event</button><button className="icon-button delete-event" type="button" disabled={remove.isPending} aria-label="Delete event" title="Delete event" onClick={() => { if (window.confirm(language === 'ru' ? 'Удалить это событие навсегда?' : 'Delete this event permanently?')) remove.mutate() }}><X /></button></div>}</div>
<div className="field-grid"><label>Event name<input required value={form.name} onChange={(e) => set('name', e.target.value)} /></label><label>Start time<input required type="datetime-local" value={form.startsAt} onChange={(e) => set('startsAt', e.target.value)} /></label><label>End time<input required type="datetime-local" value={form.endsAt} onChange={(e) => set('endsAt', e.target.value)} /></label><label>Registration deadline<input required type="datetime-local" value={form.registrationDeadline} onChange={(e) => set('registrationDeadline', e.target.value)} /></label></div>
<label>Description<textarea value={form.description} onChange={(e) => set('description', e.target.value)} /></label>
<div className="form-footer"><p>{save.isError ? save.error.message : cancel.isError ? cancel.error.message : remove.isError ? remove.error.message : 'Times are sent to the server in UTC.'}</p><button className="button primary" disabled={save.isPending}>{save.isPending ? 'Saving…' : event ? 'Save changes' : 'Create event'}</button></div>
</form>
<aside className="card admin-summary"><h2>Server authority</h2><div className="readiness-score"><Shield /><span>Validation runs on the backend</span></div><ul><li><Check />UTC scheduling</li><li><Check />Audited admin changes</li><li><Check />Registration deadline rules</li></ul></aside>
</div>
}
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<Registration[]>(['registrations', eventId], (current) => current?.filter((registration) => registration.player.id !== playerId) ?? [])
if (!demoMode) void client.invalidateQueries({ queryKey: ['events'] })
},
})
if (rows.isLoading) return <LoadingState label="Loading participants…" />
if (rows.isError || !rows.data) return <ErrorState retry={() => void rows.refetch()} />
const names = new Map(rows.data.map((registration) => [registration.player.id, registration.player.displayName]))
return <section className="card table-card"><form className="guest-participant-form" onSubmit={(event) => { event.preventDefault(); create.mutate() }}><div><h2>Add participant</h2><p>Create a guest player when they cannot sign in with Discord.</p></div><div className="guest-participant-fields"><label>Nickname<input required maxLength={80} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>{(['tank', 'damage', 'support'] as PlayerRole[]).map((role) => <label key={role}>{role === 'tank' ? 'Tank rank' : role === 'damage' ? 'Damage rank' : 'Support rank'}<select value={ratings[role]} onChange={(event) => setRatings((current) => ({ ...current, [role]: toRankOrdinal(Number(event.target.value)) }))}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>)}<label>Status<select value={status} onChange={(event) => setStatus(event.target.value as RsvpStatus)}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></label><button className="button primary" disabled={create.isPending || !displayName.trim()}>{create.isPending ? 'Adding…' : 'Add participant'}</button></div>{create.isError && <p className="error-note">{create.error.message}</p>}</form><div className="section-title"><div><h2>Participant responses</h2><p>Admin overrides are attributed and audited.</p></div><div className="count-pills"><Badge tone="success">{rows.data.filter((r) => r.status === 'going').length} going</Badge><Badge tone="warning">{rows.data.filter((r) => r.status === 'maybe').length} maybe</Badge></div></div><div className="participant-list">{rows.data.map((r) => <div className="participant-row" key={r.id}><div className="mini-avatar">{r.player.displayName.slice(0, 2).toUpperCase()}</div><div className="participant-name"><strong>{r.player.displayName}</strong><span>{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' && <> · <em>set by {r.changedBy.displayName}</em></>}</span></div><div className="mini-ratings"><span>T · {rankLabel(r.player.ratings.tank, language)}</span><span>D · {rankLabel(r.player.ratings.damage, language)}</span><span>S · {rankLabel(r.player.ratings.support, language)}</span></div><select disabled={override.isPending} aria-label={`RSVP for ${r.player.displayName}`} value={r.status} onChange={(e) => override.mutate({ playerId: r.player.id, status: e.target.value as RsvpStatus })}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></div>)}</div>{override.isError && <p className="error-note">{override.error.message}</p>}</section>
return <section className="card table-card"><form className="guest-participant-form" onSubmit={(event) => { event.preventDefault(); create.mutate() }}><div><h2>Add participant</h2><p>Create a guest player when they cannot sign in with Discord.</p></div><div className="guest-participant-fields"><label>Nickname<input required maxLength={80} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>{(['tank', 'damage', 'support'] as PlayerRole[]).map((role) => <label key={role}>{role === 'tank' ? 'Tank rank' : role === 'damage' ? 'Damage rank' : 'Support rank'}<select value={ratings[role]} onChange={(event) => setRatings((current) => ({ ...current, [role]: toRankOrdinal(Number(event.target.value)) }))}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>)}<label>Status<select value={status} onChange={(event) => setStatus(event.target.value as RsvpStatus)}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></label><button className="button primary" disabled={create.isPending || !displayName.trim()}>{create.isPending ? 'Adding…' : 'Add participant'}</button></div>{create.isError && <p className="error-note">{create.error.message}</p>}</form><div className="section-title"><div><h2>Participant responses</h2><p>Admin overrides are attributed and audited.</p></div><div className="count-pills"><Badge tone="success">{rows.data.filter((r) => r.status === 'going').length} going</Badge><Badge tone="warning">{rows.data.filter((r) => r.status === 'maybe').length} maybe</Badge></div></div><div className="participant-list">{rows.data.map((r) => <div className="participant-row" key={r.id}><div className="mini-avatar">{r.player.displayName.slice(0, 2).toUpperCase()}</div><div className="participant-name"><strong>{r.player.displayName}</strong><span>{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' && <> · <em>set by {r.changedBy.displayName}</em></>}</span></div><div className="mini-ratings"><span>T · {rankLabel(r.player.ratings.tank, language)}</span><span>D · {rankLabel(r.player.ratings.damage, language)}</span><span>S · {rankLabel(r.player.ratings.support, language)}</span></div><select disabled={override.isPending || remove.isPending} aria-label={`RSVP for ${r.player.displayName}`} value={r.status} onChange={(e) => override.mutate({ playerId: r.player.id, status: e.target.value as RsvpStatus })}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select><button className="icon-button remove-participant" type="button" disabled={remove.isPending} aria-label={`Remove ${r.player.displayName} from event`} title="Remove from event" onClick={() => remove.mutate(r.player.id)}><X /></button></div>)}</div>{(override.isError || remove.isError) && <p className="error-note">{override.error?.message ?? remove.error?.message}</p>}</section>
}
function BalanceView({ eventId }: { eventId: string }) {
if (demoMode) return <LegacyBalanceView eventId={eventId} />
return <WorkflowControl eventId={eventId} />
}
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<Awaited<ReturnType<typeof api.generateWorkflowBalance>>['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 <LoadingState label="Loading scrim workflow…" />
if (event.isError || players.isError || !event.data || !players.data) return <ErrorState retry={() => { void event.refetch(); void players.refetch() }} />
const state = event.data.workflowState
const error = close.error ?? generate.error ?? select.error ?? confirm.error ?? start.error
return <div className="workflow-control">
<section className="card workflow-progress">
{(['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'] as const).map((step, index) => {
const activeIndex = ['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].indexOf(state)
return <div className={index <= activeIndex ? 'done' : ''} key={step}><span>{index + 1}</span><strong>{step.replace(/([a-z])([A-Z])/g, '$1 $2')}</strong></div>
})}
</section>
{state === 'RegistrationOpen' && <WorkflowAction title="Close registration" description="Freeze the Going participant list before balancing." button="Close registration" pending={close.isPending} onClick={() => close.mutate()} />}
{state === 'RegistrationClosed' && <WorkflowAction title="Generate fair teams" description="The optimizer considers ranks, preferred roles, and preferred teammates." button="Generate balance" pending={generate.isPending} onClick={() => generate.mutate()} />}
{state === 'Balancing' && candidates.length === 0 && <WorkflowAction title="Generate balance candidates" description="Generate again after a refresh to choose one of the server candidates." button="Generate candidates" pending={generate.isPending} onClick={() => generate.mutate()} />}
{state === 'Balancing' && candidates.length > 0 && <div className="balance-layout"><aside className="candidate-list">{candidates.map((candidate, index) => <button className={`candidate-card ${selectedCandidate === index ? 'active' : ''}`} key={candidate.id} onClick={() => setSelectedCandidate(index)}><span className="candidate-rank">0{index + 1}</span><span><strong>Score {candidate.score}</strong><small>{candidate.explanations[0]}</small></span><ChevronRight /></button>)}</aside><section><div className="teams-grid">{candidates[selectedCandidate].teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} />)}</div><div className="sticky-action"><p>Selecting creates a versioned roster draft.</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}>Use this balance</button></div></section></div>}
{roster.data && <WorkflowRosterEditor eventId={eventId} roster={roster.data} players={players.data} locked={state !== 'RostersDraft'} onChanged={refresh} />}
{state === 'RostersDraft' && roster.data && <WorkflowAction title="Confirm rosters" description="Every team must have a valid 1/2/2 lineup and a captain." button="Confirm rosters" pending={confirm.isPending} onClick={() => confirm.mutate()} />}
{state === 'RostersConfirmed' && <WorkflowAction title="Start scrim" description="This atomically creates the Bo3 series or tournament bracket." button="Start scrim" pending={start.isPending} onClick={() => start.mutate()} />}
{state === 'Live' && <section className="card workflow-action"><div><h2>Scrim is live</h2><p>Coin toss, bans, and map results are now available.</p></div>{event.data.activeSeriesId ? <Link className="button primary" to="/series/$seriesId" params={{ seriesId: event.data.activeSeriesId }}>Open series</Link> : <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}</section>}
{state === 'Completed' && <section className="card workflow-action"><div><h2>Scrim completed</h2><p>The final result and full audit trail are preserved.</p></div></section>}
{error && <p className="error-note">{error.message}</p>}
</div>
}
function WorkflowAction({ title, description, button, pending, onClick }: { title: string; description: string; button: string; pending: boolean; onClick: () => void }) {
return <section className="card workflow-action"><div><h2>{title}</h2><p>{description}</p></div><button className="button primary" disabled={pending} onClick={onClick}>{pending ? 'Working…' : button}</button></section>
}
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 <section className="card roster-editor"><div className="section-title"><div><h2>Roster editor</h2><p>Version {roster.version} · swaps are restricted to the same role.</p></div>{roster.confirmed && <Badge tone="success"><Check />Locked</Badge>}</div><div className="teams-grid">{roster.teams.map((team) => <article className="workflow-team" key={team.id}><header><h3>{team.name}</h3><label>Captain<select disabled={locked || mutate.isPending} value={team.captainPlayerId} onChange={(event) => mutate.mutate({ kind: 'captain', teamId: team.id, playerId: event.target.value })}><option value="">Choose captain</option>{team.slots.map((slot) => <option value={slot.playerId} key={slot.playerId}>{names.get(slot.playerId) ?? slot.playerId}</option>)}</select></label></header>{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 <div className="roster-slot" key={slot.playerId}><span className={`role-chip ${slot.role.toLowerCase()}`}>{slot.role[0]}</span><strong>{names.get(slot.playerId) ?? slot.playerId}</strong><small>{rankLabel(slot.rating, language)}</small>{!locked && <><select aria-label={`Swap ${names.get(slot.playerId)}`} defaultValue="" disabled={mutate.isPending} onChange={(event) => { const [teamBId, playerBId] = event.target.value.split(':'); if (playerBId) mutate.mutate({ kind: 'swap', teamAId: team.id, playerAId: slot.playerId, teamBId, playerBId }) }}><option value="">Swap</option>{swapOptions.map((candidate) => <option key={candidate.playerId} value={`${candidate.teamId}:${candidate.playerId}`}>{names.get(candidate.playerId)}</option>)}</select>{roster.reserve.length > 0 && <select aria-label={`Substitute ${names.get(slot.playerId)}`} defaultValue="" disabled={mutate.isPending} onChange={(event) => event.target.value && mutate.mutate({ kind: 'substitute', teamId: team.id, outgoingPlayerId: slot.playerId, reservePlayerId: event.target.value })}><option value="">Reserve</option>{roster.reserve.map((id) => <option value={id} key={id}>{names.get(id) ?? id}</option>)}</select>}</>}</div>
})}</article>)}</div>{roster.reserve.length > 0 && <div className="reserve-bar"><strong>Reserve</strong>{roster.reserve.map((id) => <Badge key={id}>{names.get(id) ?? id}</Badge>)}</div>}{mutate.isError && <p className="error-note">{mutate.error.message}</p>}</section>
}
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<string>()
const [mapName, setMapName] = useState('')
const [outcome, setOutcome] = useState<MapOutcome>('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 <LoadingState />
if (series.isError) return <ErrorState retry={() => void series.refetch()} />
if (!series.data) return <LoadingState />
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 <LoadingState />
if (series.isError || session.isError) return <ErrorState retry={() => { void series.refetch(); void session.refetch() }} />
if (!series.data || !session.data) return <LoadingState />
const data = series.data
return <div className="page live-page"><PageHeader eyebrow={`${data.roundLabel} · ${data.status}`} title="Series control" description="Actions are validated and recorded by the server." actions={<Link className="button secondary" to="/watch/$seriesId" params={{ seriesId: data.id }}><Radio />Spectator view</Link>} /><Scoreboard series={data} /><div className="live-layout"><section className={`turn-card team-${data.currentStep.activeTeam ?? 'alpha'}`}><div className="turn-header"><div><span className="eyebrow">Current step</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p></div>{data.currentStep.activeTeam && <div className="turn-team"><span>Acting team</span><strong>{data.currentStep.activeTeam === 'alpha' ? data.teamAlpha.name : data.teamBeta.name}</strong></div>}</div>{data.currentStep.kind === 'result' ? <form className="result-form" onSubmit={(e) => { e.preventDefault(); result.mutate() }}><label>Map name<input required value={mapName} onChange={(e) => setMapName(e.target.value)} placeholder="e.g. Lijiang Tower" /></label><label>Outcome<select value={outcome} onChange={(e) => setOutcome(e.target.value as MapOutcome)}><option value="TeamAWin">{data.teamAlpha.name} wins</option><option value="TeamBWin">{data.teamBeta.name} wins</option><option value="Draw">Draw</option></select></label><button className="button team-action" disabled={result.isPending}>{result.isPending ? 'Recording…' : 'Record result'}</button>{result.isError && <p className="error-note">{result.error.message}</p>}</form> : <><div className="draft-options" role="radiogroup" aria-label="Available draft choices">{data.options.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled} className={selected === option.id ? 'selected' : ''} onClick={() => setSelected(option.id)}><span className={`role-chip ${option.role}`}>{option.role?.slice(0, 1).toUpperCase()}</span><strong>{option.name}</strong>{option.disabled ? <small>{option.disabledReason}</small> : <Check />}</button>)}</div><div className="turn-footer"><p><Shield />Draft commands use dedicated backend endpoints and server-side validation.</p><button className="button team-action" disabled={!selected}>Confirm action <ChevronRight /></button></div></>}</section><aside className="match-sidebar"><MapTimeline series={data} /><AuditLog series={data} /></aside></div></div>
return <div className="page live-page"><PageHeader eyebrow={`${data.roundLabel} · ${data.status}`} title="Series control" description="Actions are validated and recorded by the server." actions={<Link className="button secondary" to="/watch/$seriesId" params={{ seriesId: data.id }}><Radio />Spectator view</Link>} /><Scoreboard series={data} /><div className="live-layout"><section className={`turn-card team-${data.currentStep.activeTeam ?? 'alpha'}`}><div className="turn-header"><div><span className="eyebrow">Current step</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p></div>{data.currentStep.activeTeam && <div className="turn-team"><span>Acting team</span><strong>{data.currentStep.activeTeam === 'alpha' ? data.teamAlpha.name : data.teamBeta.name}</strong></div>}</div>{data.currentStep.kind === 'result' ? <form className="result-form" onSubmit={(e) => { e.preventDefault(); result.mutate() }}><label>Outcome<select value={outcome} onChange={(e) => setOutcome(e.target.value as MapOutcome)}><option value="TeamAWin">{data.teamAlpha.name} wins</option><option value="TeamBWin">{data.teamBeta.name} wins</option><option value="Draw">Draw</option></select></label><button className="button team-action" disabled={result.isPending}>{result.isPending ? 'Recording…' : 'Record result'}</button>{result.isError && <p className="error-note">{result.error.message}</p>}</form> : data.currentStep.kind === 'complete' ? <div className="turn-footer"><p><Check />Series completed and bracket updated.</p></div> : <><div className="draft-options" role="radiogroup" aria-label="Available draft choices">{data.options.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled} className={selected === option.id ? 'selected' : ''} onClick={() => setSelected(option.id)}><span className={`role-chip ${option.role}`}>{option.role?.slice(0, 1).toUpperCase()}</span><strong>{option.name}</strong>{option.disabled ? <small>{option.disabledReason}</small> : <Check />}</button>)}</div><div className="turn-footer"><p><Shield />Draft commands use dedicated backend endpoints and server-side validation.</p><button className="button team-action" disabled={(data.currentStep.kind !== 'coin_toss' && !selected) || action.isPending} onClick={() => action.mutate()}>{action.isPending ? 'Confirming…' : data.currentStep.kind === 'coin_toss' ? 'Toss coin' : 'Confirm action'} <ChevronRight /></button></div>{action.isError && <p className="error-note">{action.error.message}</p>}</>}</section><aside className="match-sidebar"><MapTimeline series={data} /><AuditLog series={data} /></aside></div></div>
}
function Scoreboard({ series }: { series: Series }) {
@@ -344,13 +558,11 @@ function AuditLog({ series }: { series: Series }) { return <section className="c
function SpectatorPage() {
const { seriesId } = spectatorRoute.useParams()
const client = useQueryClient()
const series = useQuery({ queryKey: ['series', seriesId], queryFn: () => api.series(seriesId), initialData: demoMode && seriesId === demoSeries.id ? demoSeries : undefined })
useEffect(() => subscribeToEvents(`series:${seriesId}`, () => void client.invalidateQueries({ queryKey: ['series', seriesId] })), [client, seriesId])
if (series.isLoading) return <LoadingState />
if (series.isError || !series.data) return <ErrorState retry={() => void series.refetch()} />
const data = series.data
return <div className="page spectator-page"><PageHeader eyebrow="Spectator mode" title={data.roundLabel} description="Read-only live view · Updates automatically" actions={<Badge tone="success"><span className="live-dot" />Connected</Badge>} /><Scoreboard series={data} /><section className="spectator-focus"><span className="eyebrow">{data.status}</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p>{demoMode && data.currentStep.kind === 'hero_ban' && <div className="ban-display">{data.maps.at(-1)?.heroBans.map((ban, index) => <div key={`${ban.hero}-${index}`}><span>Ban {index + 1}</span><strong>{ban.hero}</strong><small>{ban.role}</small></div>)}</div>}</section><div className="spectator-grid"><MapTimeline series={data} /><AuditLog series={data} /></div></div>
return <div className="page spectator-page"><PageHeader eyebrow="Spectator mode" title={data.roundLabel} description="Read-only live view · Updates automatically" actions={<Badge tone="success"><span className="live-dot" />Connected</Badge>} /><Scoreboard series={data} /><section className="spectator-focus"><span className="eyebrow">{data.status}</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p>{data.maps.at(-1)?.heroBans.length ? <div className="ban-display">{data.maps.at(-1)?.heroBans.map((ban, index) => <div key={`${ban.hero}-${index}`}><span>Ban {index + 1}</span><strong>{ban.hero}</strong><small>{ban.role}</small></div>)}</div> : null}</section><div className="spectator-grid"><MapTimeline series={data} /><AuditLog series={data} /></div></div>
}
function BracketPage() {

View File

@@ -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 () => {

View File

@@ -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<RsvpStatus, number>
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<string, Player>, session?: Session): Registration {
@@ -220,6 +249,7 @@ export function adaptRegistration(raw: RawRSVP, players: Map<string, Player>, 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<string, Player>, 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<RawTournament>(`/tournaments/${series.tournamentId}`)
const [teams, players] = await Promise.all([raw.teams(tournament.eventId), raw.players()])
const eventId = series.eventId || (series.tournamentId ? (await request<RawTournament>(`/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<Ratings, PlayerRole>,
preferredRoles: PlayerRole[],
preferredPlayerIds: string[],
avoidedPlayerIds: string[],
) =>
adaptPlayer(await request<RawPlayer>('/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<void>(`/events/${eventId}/rsvps/${playerId}`, { method: 'DELETE' }),
createEvent: async (event: EventInput) => adaptEvent(await request<RawEvent>('/events', { method: 'POST', body: JSON.stringify(event) })),
updateEvent: async (eventId: string, event: EventInput) => adaptEvent(await request<RawEvent>(`/events/${eventId}`, { method: 'PUT', body: JSON.stringify(event) })),
cancelEvent: async (eventId: string, expectedVersion: number) =>
adaptEvent(await request<RawEvent>(`/events/${eventId}/cancel`, { method: 'POST', body: JSON.stringify({ expectedVersion }) })),
deleteEvent: (eventId: string) => request<void>(`/events/${eventId}`, { method: 'DELETE' }),
generateBalance: async (eventId: string) => {
const [candidates, players] = await Promise.all([
request<RawBalanceCandidate[]>(`/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<RawEvent>(`/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<RawRosterDraft>(`/events/${eventId}/roster`),
selectWorkflowBalance: (eventId: string, candidate: BalanceCandidate, expectedVersion: number) =>
request<RawRosterDraft>(`/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<RawRosterDraft>(`/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<RawRosterDraft>(`/events/${eventId}/roster/${emergency ? 'emergency-substitute' : 'substitute'}`, { method: 'POST', body: JSON.stringify(input) }),
setRosterCaptain: (eventId: string, teamId: string, playerId: string, expectedVersion: number) =>
request<RawRosterDraft>(`/events/${eventId}/roster/captain`, {
method: 'PUT',
body: JSON.stringify({ teamId, playerId, expectedVersion }),
}),
confirmRosters: (eventId: string, expectedVersion: number, expectedRosterVersion: number) =>
request<RawEvent>(`/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<RawSeries>(`/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<RawSeries>(`/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<RawSeries>(`/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<RawSeries>(`/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<RawSeries>(`/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<RawSeries>(`/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')

View File

@@ -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}`,

View File

@@ -7,6 +7,7 @@ const translations: Record<string, string> = {
'Bracket': 'Сетка',
'Admin': 'Админ',
'Profile': 'Профиль',
'FAQ': 'FAQ',
'Language': 'Язык',
'Primary navigation': 'Основная навигация',
'Mobile navigation': 'Мобильная навигация',
@@ -15,6 +16,7 @@ const translations: Record<string, string> = {
'Sign out': 'Выйти',
'Toggle navigation': 'Открыть навигацию',
'Realtime connected': 'Онлайн-соединение установлено',
'Realtime reconnecting': 'Переподключение к обновлениям',
'Community scrims, organized': 'Организованные командные миксы',
'Ready up.': 'Приготовьтесь.',
'Play together.': 'Играйте вместе.',
@@ -88,6 +90,8 @@ const translations: Record<string, string> = {
'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<string, string> = {
'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<string, string> = {
'Support rank': 'Ранг поддержки',
'Status': 'Статус',
'Adding…': 'Добавление…',
'Remove from event': 'Удалить из микса',
'Not going': 'Не идёт',
'Preferred with': 'Предпочитает играть с',
'No preferred roles': 'Нет предпочитаемых ролей',
@@ -191,6 +201,52 @@ const translations: Record<string, string> = {
'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 Cant 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 teams 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) {

View File

@@ -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; }

View File

@@ -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.

View File

@@ -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 140 только для внутренних вычислений. Жёсткие ограничения обеспечивают состав 1/2/2 и уникальность игроков. Мягкие штрафы учитывают общий и ролевой разброс рейтинга, назначение вне предпочитаемой роли и разделение желаемых союзников. После построения нескольких начальных вариантов выполняется локальный поиск обменами игроков одной роли.
В MVP — детерминированный оптимизатор с весовой функцией качества и ограниченным поиском вариантов. Overwatch-ранги Bronze 5 → Champion 1 отображаются в ordinal 140 только для внутренних вычислений. Жёсткие ограничения обеспечивают состав 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 и держит соединение открытым.
### Капитаны

View File

@@ -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 140.
Игрок редактирует ранги самостоятельно; 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;

View File

@@ -8,7 +8,7 @@ Mixmaker — веб-приложение для организации люби
- регистрация участников и оценка их силы по ролям;
- вход через Discord и самостоятельный выбор ранга Bronze 5 → Champion 1 для каждой роли;
- выбор предпочитаемых ролей и до трёх желаемых союзников;
- выбор предпочитаемых ролей, до трёх желаемых союзников и до трёх игроков, которых желательно избегать;
- переключение интерфейса между русским и английским языком;
- календарь запланированных миксов и отметки об участии;
- административная панель для управления событиями, участниками и их отметками;

View File

@@ -1,6 +1,20 @@
# Дорожная карта
Статус на 19 июля 2026: технический MVP этапов 04 реализован. Следующий контрольный рубеж — развёртывание в 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-предпочтений в целевой функции;
- ручная перестановка игроков с пересчётом показателей.
- назначение и переназначение капитана каждой команды.

View File

@@ -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: