Add Discord event announcement functionality to backend
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit introduces a new Discord event announcer to the backend, allowing for event announcements via Discord. It includes the addition of new environment variables for Discord configuration in `.env.example` and `compose.yaml`. The `main.go` file has been updated to initialize the announcer, and a new `discord` package has been created, containing the announcer logic and tests. Additionally, the service layer has been modified to support bracket draft management, enhancing the overall event workflow. Integration tests have been updated to ensure proper functionality of the new features.
This commit is contained in:
2026-07-19 11:21:11 +03:00
parent b7a78b4384
commit ae19c03542
31 changed files with 1632 additions and 88 deletions

View File

@@ -18,5 +18,10 @@ DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URL=http://localhost:5173/api/auth/discord/callback
ADMIN_DISCORD_IDS=
# Discord event announcements (optional)
DISCORD_BOT_TOKEN=
DISCORD_ANNOUNCEMENT_CHANNEL_ID=
DISCORD_ANNOUNCEMENT_LOCALE=ru
# Browser-facing build configuration
VITE_API_BASE_URL=/api

View File

@@ -11,6 +11,7 @@ import (
"syscall"
"time"
discordadapter "mixmaker/backend/internal/adapter/discord"
"mixmaker/backend/internal/adapter/httpapi"
"mixmaker/backend/internal/adapter/postgres"
"mixmaker/backend/internal/application"
@@ -37,7 +38,22 @@ func main() {
}
defer store.Close()
hub := realtime.New()
service := application.New(store, hub)
var eventAnnouncer application.EventAnnouncer
discordBotToken := os.Getenv("DISCORD_BOT_TOKEN")
discordAnnouncementChannelID := os.Getenv("DISCORD_ANNOUNCEMENT_CHANNEL_ID")
if discordBotToken != "" || discordAnnouncementChannelID != "" {
eventAnnouncer, err = discordadapter.New(discordadapter.Config{
BotToken: discordBotToken,
ChannelID: discordAnnouncementChannelID,
PublicURL: env("PUBLIC_URL", env("FRONTEND_URL", "")),
Locale: env("DISCORD_ANNOUNCEMENT_LOCALE", "ru"),
})
if err != nil {
slog.Error("Discord announcer configuration failed", "error", err)
os.Exit(2)
}
}
service := application.New(store, hub, eventAnnouncer)
cfg := httpapi.Config{
DiscordClientID: required("DISCORD_CLIENT_ID"),
DiscordClientSecret: required("DISCORD_CLIENT_SECRET"),

View File

@@ -0,0 +1,203 @@
package discord
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"unicode/utf8"
"mixmaker/backend/internal/domain"
)
const defaultAPIBaseURL = "https://discord.com/api/v10"
type Config struct {
BotToken string
ChannelID string
PublicURL string
Locale string
APIBaseURL string
HTTPClient *http.Client
}
type Announcer struct {
botToken string
channelID string
publicURL string
locale string
apiBaseURL string
httpClient *http.Client
}
type messagePayload struct {
Content string `json:"content"`
Embeds []embed `json:"embeds"`
Components []actionRow `json:"components"`
AllowedMentions allowedMentions `json:"allowed_mentions"`
}
type embed struct {
Title string `json:"title"`
Description string `json:"description,omitempty"`
URL string `json:"url"`
Color int `json:"color"`
Fields []embedField `json:"fields"`
}
type embedField struct {
Name string `json:"name"`
Value string `json:"value"`
Inline bool `json:"inline"`
}
type actionRow struct {
Type int `json:"type"`
Components []button `json:"components"`
}
type button struct {
Type int `json:"type"`
Style int `json:"style"`
Label string `json:"label"`
URL string `json:"url"`
}
type allowedMentions struct {
Parse []string `json:"parse"`
}
type translations struct {
titlePrefix string
startsAt string
deadline string
registerButton string
}
func New(config Config) (*Announcer, error) {
if strings.TrimSpace(config.BotToken) == "" {
return nil, errors.New("discord bot token is required")
}
if strings.TrimSpace(config.ChannelID) == "" {
return nil, errors.New("discord announcement channel ID is required")
}
publicURL, err := url.Parse(strings.TrimSpace(config.PublicURL))
if err != nil || (publicURL.Scheme != "http" && publicURL.Scheme != "https") || publicURL.Host == "" {
return nil, errors.New("discord announcement public URL must be an absolute HTTP(S) URL")
}
locale := strings.ToLower(strings.TrimSpace(config.Locale))
if locale == "" {
locale = "ru"
}
if locale != "ru" && locale != "en" {
return nil, fmt.Errorf("unsupported discord announcement locale %q", locale)
}
apiBaseURL := strings.TrimRight(config.APIBaseURL, "/")
if apiBaseURL == "" {
apiBaseURL = defaultAPIBaseURL
}
httpClient := config.HTTPClient
if httpClient == nil {
httpClient = http.DefaultClient
}
return &Announcer{
botToken: config.BotToken,
channelID: config.ChannelID,
publicURL: strings.TrimRight(strings.TrimSpace(config.PublicURL), "/"),
locale: locale,
apiBaseURL: apiBaseURL,
httpClient: httpClient,
}, nil
}
func (a *Announcer) AnnounceEventCreated(ctx context.Context, event domain.Event) error {
err := a.announceEventCreated(ctx, event)
if err != nil {
slog.Error("Discord event announcement failed", "event_id", event.ID, "error", err)
}
return err
}
func (a *Announcer) announceEventCreated(ctx context.Context, event domain.Event) error {
payload, err := json.Marshal(a.createdMessage(event))
if err != nil {
return fmt.Errorf("encode Discord message: %w", err)
}
endpoint := fmt.Sprintf("%s/channels/%s/messages", a.apiBaseURL, url.PathEscape(a.channelID))
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("create Discord request: %w", err)
}
request.Header.Set("Authorization", "Bot "+a.botToken)
request.Header.Set("Content-Type", "application/json")
response, err := a.httpClient.Do(request)
if err != nil {
return fmt.Errorf("send Discord message: %w", err)
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
return fmt.Errorf("Discord returned %s: %s", response.Status, strings.TrimSpace(string(body)))
}
return nil
}
func (a *Announcer) createdMessage(event domain.Event) messagePayload {
text := announcementTranslations(a.locale)
eventURL := a.publicURL + "/events/" + url.PathEscape(event.ID)
return messagePayload{
Content: "@everyone",
Embeds: []embed{{
Title: truncate(text.titlePrefix+event.Name, 256),
Description: truncate(event.Description, 4096),
URL: eventURL,
Color: 0xe9358f,
Fields: []embedField{
{Name: text.startsAt, Value: fmt.Sprintf("<t:%d:F> · <t:%d:R>", event.StartsAt.Unix(), event.StartsAt.Unix()), Inline: true},
{Name: text.deadline, Value: fmt.Sprintf("<t:%d:F>", event.RegistrationDeadline.Unix()), Inline: true},
},
}},
Components: []actionRow{{
Type: 1,
Components: []button{{
Type: 2,
Style: 5,
Label: text.registerButton,
URL: eventURL,
}},
}},
AllowedMentions: allowedMentions{Parse: []string{"everyone"}},
}
}
func announcementTranslations(locale string) translations {
if locale == "en" {
return translations{
titlePrefix: "New mix: ",
startsAt: "Starts",
deadline: "Registration deadline",
registerButton: "Register",
}
}
return translations{
titlePrefix: "Новый микс: ",
startsAt: "Начало",
deadline: "Регистрация до",
registerButton: "Зарегистрироваться",
}
}
func truncate(value string, maxRunes int) string {
if utf8.RuneCountInString(value) <= maxRunes {
return value
}
runes := []rune(value)
return string(runes[:maxRunes-1]) + "…"
}

View File

@@ -0,0 +1,97 @@
package discord
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"mixmaker/backend/internal/domain"
)
func TestAnnounceEventCreated(t *testing.T) {
var received messagePayload
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != "/channels/channel-123/messages" {
t.Errorf("unexpected request: %s %s", request.Method, request.URL.Path)
}
if authorization := request.Header.Get("Authorization"); authorization != "Bot secret-token" {
t.Errorf("unexpected Authorization header: %q", authorization)
}
if err := json.NewDecoder(request.Body).Decode(&received); err != nil {
t.Errorf("decode payload: %v", err)
}
response.WriteHeader(http.StatusCreated)
}))
defer server.Close()
announcer, err := New(Config{
BotToken: "secret-token",
ChannelID: "channel-123",
PublicURL: "https://mix.example.com",
Locale: "ru",
APIBaseURL: server.URL,
HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
start := time.Date(2026, time.July, 20, 18, 0, 0, 0, time.UTC)
event := domain.Event{
ID: "event-456",
Name: "Воскресный микс",
Description: "Собираемся на Bo3",
StartsAt: start,
RegistrationDeadline: start.Add(-2 * time.Hour),
}
if err := announcer.AnnounceEventCreated(context.Background(), event); err != nil {
t.Fatal(err)
}
if received.Content != "@everyone" {
t.Fatalf("unexpected content: %q", received.Content)
}
if len(received.AllowedMentions.Parse) != 1 || received.AllowedMentions.Parse[0] != "everyone" {
t.Fatalf("unexpected allowed mentions: %+v", received.AllowedMentions)
}
if len(received.Embeds) != 1 {
t.Fatalf("expected one embed, got %d", len(received.Embeds))
}
eventURL := "https://mix.example.com/events/event-456"
if received.Embeds[0].Title != "Новый микс: Воскресный микс" ||
received.Embeds[0].Description != event.Description ||
received.Embeds[0].URL != eventURL ||
len(received.Embeds[0].Fields) != 2 {
t.Fatalf("unexpected embed: %+v", received.Embeds[0])
}
if len(received.Components) != 1 || len(received.Components[0].Components) != 1 {
t.Fatalf("unexpected components: %+v", received.Components)
}
linkButton := received.Components[0].Components[0]
if linkButton.Style != 5 || linkButton.Label != "Зарегистрироваться" || linkButton.URL != eventURL {
t.Fatalf("unexpected registration button: %+v", linkButton)
}
}
func TestAnnounceEventCreatedReturnsDiscordError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
http.Error(response, `{"message":"Missing Permissions"}`, http.StatusForbidden)
}))
defer server.Close()
announcer, err := New(Config{
BotToken: "secret-token",
ChannelID: "channel-123",
PublicURL: "https://mix.example.com",
APIBaseURL: server.URL,
HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if err := announcer.AnnounceEventCreated(context.Background(), domain.Event{ID: "event-456"}); err == nil {
t.Fatal("expected Discord error")
}
}

View File

@@ -93,6 +93,11 @@ func New(service *application.Service, store application.Store, hub *realtime.Hu
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.Get("/api/events/{eventID}/bracket-draft", s.getBracketDraft)
api.Post("/api/events/{eventID}/bracket-draft/initialize", s.initializeBracket)
api.Put("/api/events/{eventID}/bracket-draft", s.updateBracket)
api.Post("/api/events/{eventID}/bracket-draft/reset", s.resetBracket)
api.Post("/api/events/{eventID}/bracket-draft/confirm", s.confirmBracket)
api.Post("/api/events/{eventID}/workflow/back", s.revertWorkflowStage)
api.Post("/api/events/{eventID}/start", s.startScrim)
api.Put("/api/teams/{teamID}/captain", s.assignCaptain)

View File

@@ -168,6 +168,56 @@ func (s *Server) confirmRosters(w http.ResponseWriter, r *http.Request) {
respond(w, out, err, http.StatusOK)
}
func (s *Server) getBracketDraft(w http.ResponseWriter, r *http.Request) {
out, err := s.store.GetBracketDraft(r.Context(), chi.URLParam(r, "eventID"))
respond(w, out, err, http.StatusOK)
}
func (s *Server) initializeBracket(w http.ResponseWriter, r *http.Request) {
var in struct {
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.InitializeBracket(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion)
respond(w, out, err, http.StatusCreated)
}
func (s *Server) updateBracket(w http.ResponseWriter, r *http.Request) {
var in struct {
Matches []domain.BracketMatch `json:"matches"`
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.UpdateBracket(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.Matches, in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) resetBracket(w http.ResponseWriter, r *http.Request) {
var in struct {
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.ResetBracket(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) confirmBracket(w http.ResponseWriter, r *http.Request) {
var in struct {
ExpectedVersion int `json:"expectedVersion"`
}
if !decode(w, r, &in) {
return
}
out, err := s.service.ConfirmBracket(r.Context(), who(r).account, chi.URLParam(r, "eventID"), in.ExpectedVersion)
respond(w, out, err, http.StatusOK)
}
func (s *Server) revertWorkflowStage(w http.ResponseWriter, r *http.Request) {
var in struct {
ExpectedVersion int `json:"expectedVersion"`

View File

@@ -371,6 +371,40 @@ func (s *Store) ResetRoster(ctx context.Context, eventID string) error {
return tx.Commit(ctx)
}
func (s *Store) SaveBracketDraft(ctx context.Context, draft domain.BracketDraft, expectedVersion int) (domain.BracketDraft, error) {
body, _ := json.Marshal(draft)
if expectedVersion < 0 {
_, err := s.pool.Exec(ctx, `INSERT INTO event_bracket_drafts(event_id,body,version,confirmed) VALUES($1,$2,$3,$4)
ON CONFLICT(event_id) DO UPDATE SET body=excluded.body,version=excluded.version,confirmed=excluded.confirmed,updated_at=now()`,
draft.EventID, body, draft.Version, draft.Confirmed)
return draft, err
}
tag, err := s.pool.Exec(ctx, `UPDATE event_bracket_drafts SET body=$2,version=$3,confirmed=$4,updated_at=now()
WHERE event_id=$1 AND version=$5`, draft.EventID, body, draft.Version, draft.Confirmed, expectedVersion)
if err == nil && tag.RowsAffected() == 0 {
err = domain.ErrConflict
}
return draft, err
}
func (s *Store) GetBracketDraft(ctx context.Context, eventID string) (domain.BracketDraft, error) {
var body []byte
var version int
var confirmed bool
var draft domain.BracketDraft
err := s.pool.QueryRow(ctx, `SELECT body,version,confirmed FROM event_bracket_drafts WHERE event_id=$1`, eventID).Scan(&body, &version, &confirmed)
if err == nil {
err = json.Unmarshal(body, &draft)
draft.Version, draft.Confirmed = version, confirmed
}
return draft, mapError(err)
}
func (s *Store) DeleteBracketDraft(ctx context.Context, eventID string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM event_bracket_drafts WHERE event_id=$1`, eventID)
return 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 {
@@ -386,8 +420,8 @@ func (s *Store) StartScrim(ctx context.Context, event domain.Event, expectedVers
}
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 {
if _, err = tx.Exec(ctx, `INSERT INTO tournaments(id,event_id,body,version) VALUES($1,$2,$3,$4)`,
tournament.ID, tournament.EventID, body, tournament.Version); err != nil {
return mapError(err)
}
}
@@ -600,8 +634,12 @@ func (s *Store) GetSeries(ctx context.Context, id string) (domain.Series, error)
func (s *Store) SaveTournament(ctx context.Context, t domain.Tournament) (domain.Tournament, error) {
body, _ := json.Marshal(t)
_, err := s.pool.Exec(ctx, `INSERT INTO tournaments(id,event_id,body) VALUES($1,$2,$3)
ON CONFLICT(id) DO UPDATE SET body=excluded.body`, t.ID, t.EventID, body)
tag, err := s.pool.Exec(ctx, `INSERT INTO tournaments(id,event_id,body,version) VALUES($1,$2,$3,$4)
ON CONFLICT(id) DO UPDATE SET body=excluded.body,version=excluded.version
WHERE excluded.version=0 OR tournaments.version=excluded.version-1`, t.ID, t.EventID, body, t.Version)
if err == nil && tag.RowsAffected() == 0 {
err = domain.ErrConflict
}
return t, err
}
@@ -652,6 +690,24 @@ func (s *Store) hydrateTournament(ctx context.Context, tournament *domain.Tourna
if err := rows.Err(); err != nil {
return err
}
if len(tournament.Matches) > 0 {
for index := range tournament.Matches {
match := &tournament.Matches[index]
if series, ok := live[match.SeriesID]; ok {
match.TeamAID, match.TeamBID, match.WinnerTeamID = series.TeamAID, series.TeamBID, series.WinnerTeamID
match.Series = &series
} else if series, ok := live[match.ID]; ok {
match.SeriesID = series.ID
match.TeamAID, match.TeamBID, match.WinnerTeamID = series.TeamAID, series.TeamBID, series.WinnerTeamID
match.Series = &series
}
}
if err := tournament.Resolve(); err != nil {
return err
}
tournament.BuildRounds()
return nil
}
for round := range tournament.Rounds {
for match := range tournament.Rounds[round] {
if series, ok := live[tournament.Rounds[round][match].ID]; ok {

View File

@@ -47,6 +47,13 @@ func TestMigrationsAndReadiness(t *testing.T) {
if !rosterTable {
t.Fatal("event_rosters table is missing")
}
var bracketTable bool
if err := store.pool.QueryRow(ctx, `SELECT to_regclass('public.event_bracket_drafts') IS NOT NULL`).Scan(&bracketTable); err != nil {
t.Fatal(err)
}
if !bracketTable {
t.Fatal("event_bracket_drafts table is missing")
}
}
func TestFullScrimPipeline(t *testing.T) {
@@ -84,7 +91,7 @@ func TestFullScrimPipeline(t *testing.T) {
_, _ = store.pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, accountID)
}()
service := application.New(store, discardPublisher{})
service := application.New(store, discardPublisher{}, nil)
admin := domain.Account{ID: accountID, Role: domain.RoleAdmin}
now := time.Now().UTC()
event, err := service.CreateEvent(ctx, admin, domain.Event{
@@ -130,6 +137,18 @@ func TestFullScrimPipeline(t *testing.T) {
if err != nil {
t.Fatal(err)
}
draft, err := service.InitializeBracket(ctx, admin, eventID, event.Version)
if err != nil {
t.Fatal(err)
}
draft, err = service.ConfirmBracket(ctx, admin, eventID, draft.Version)
if err != nil {
t.Fatal(err)
}
event, err = store.GetEvent(ctx, eventID)
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)
@@ -200,7 +219,7 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
_, _ = store.pool.Exec(ctx, `DELETE FROM events WHERE id=$1`, eventID)
_, _ = store.pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, accountID)
}()
service := application.New(store, discardPublisher{})
service := application.New(store, discardPublisher{}, nil)
event, err := service.CreateEvent(ctx, domain.Account{ID: accountID, Role: domain.RoleAdmin}, domain.Event{
ID: eventID, Name: "Parallel tournament", StartsAt: time.Now().Add(time.Hour), EndsAt: time.Now().Add(3 * time.Hour),
RegistrationDeadline: time.Now(), RulesetID: "standard-control-hybrid-control",
@@ -213,15 +232,15 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
if err != nil {
t.Fatal(err)
}
tournament, err := domain.NewTournament(tournamentID, eventID, event.Name, []string{"team-a", "team-b", "team-c", "team-d"})
draft, err := domain.NewBracketDraft(eventID, []string{"team-a", "team-b", "team-c", "team-d"})
if err != nil {
t.Fatal(err)
}
if _, err = store.SaveTournament(ctx, *tournament); err != nil {
tournament, err := domain.NewGraphTournament(tournamentID, eventID, event.Name, *draft)
if err != nil {
t.Fatal(err)
}
for match := range tournament.Rounds[0] {
slot := tournament.Rounds[0][match]
for match, slot := range tournament.ReadyMatches() {
series, createErr := domain.NewSeries(slot.ID, eventID, tournamentID, [2]string{slot.TeamAID, slot.TeamBID}, rules)
if createErr != nil {
t.Fatal(createErr)
@@ -234,6 +253,16 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
if _, createErr = store.SaveSeries(ctx, *series); createErr != nil {
t.Fatal(createErr)
}
for index := range tournament.Matches {
if tournament.Matches[index].ID == slot.ID {
tournament.Matches[index].SeriesID = series.ID
tournament.Matches[index].Series = series
}
}
}
tournament.BuildRounds()
if _, err = store.SaveTournament(ctx, *tournament); err != nil {
t.Fatal(err)
}
hydrated, err := store.GetTournamentByEvent(ctx, eventID)
if err != nil {
@@ -242,17 +271,23 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
if hydrated.Rounds[0][0].Phase != domain.MapBanPhase || hydrated.Rounds[0][1].Phase != domain.CoinTossPending {
t.Fatalf("parallel series were not hydrated independently: %s / %s", hydrated.Rounds[0][0].Phase, hydrated.Rounds[0][1].Phase)
}
for match := range hydrated.Rounds[0] {
hydrated.Rounds[0][match].WinnerTeamID = hydrated.Rounds[0][match].TeamAID
hydrated.Rounds[0][match].Phase = domain.SeriesComplete
if err = hydrated.Advance(0, match); err != nil {
for index := range hydrated.Matches {
if hydrated.Matches[index].Round != 0 || hydrated.Matches[index].Series == nil {
continue
}
completed := *hydrated.Matches[index].Series
completed.WinnerTeamID, completed.Phase, completed.Version = completed.TeamAID, domain.SeriesComplete, completed.Version+1
if _, err = store.SaveSeries(ctx, completed); err != nil {
t.Fatal(err)
}
if err = hydrated.ApplySeries(completed); err != nil {
t.Fatal(err)
}
if _, err = store.SaveTournament(ctx, hydrated); err != nil {
t.Fatal(err)
}
}
if _, err = store.SaveTournament(ctx, hydrated); err != nil {
t.Fatal(err)
}
finalSlot := hydrated.Rounds[1][0]
finalSlot := hydrated.ReadyMatches()[0]
final, err := domain.NewSeries(finalSlot.ID, eventID, tournamentID, [2]string{finalSlot.TeamAID, finalSlot.TeamBID}, rules)
if err != nil {
t.Fatal(err)
@@ -261,8 +296,8 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
t.Fatal(err)
}
hydrated, err = store.GetTournamentByEvent(ctx, eventID)
if err != nil || hydrated.Rounds[1][0].Phase != domain.CoinTossPending {
t.Fatalf("final was not persisted and hydrated: phase=%s err=%v", hydrated.Rounds[1][0].Phase, err)
if err != nil || hydrated.Matches[len(hydrated.Matches)-1].Series == nil || hydrated.Matches[len(hydrated.Matches)-1].Series.Phase != domain.CoinTossPending {
t.Fatalf("final was not persisted and hydrated: err=%v", err)
}
}

View File

@@ -30,6 +30,9 @@ type Store interface {
SaveRoster(context.Context, domain.RosterDraft, int) (domain.RosterDraft, error)
GetRoster(context.Context, string) (domain.RosterDraft, error)
ResetRoster(context.Context, string) error
SaveBracketDraft(context.Context, domain.BracketDraft, int) (domain.BracketDraft, error)
GetBracketDraft(context.Context, string) (domain.BracketDraft, error)
DeleteBracketDraft(context.Context, string) 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)
@@ -55,14 +58,26 @@ type Store interface {
type Publisher interface{ Publish(topic string, value any) }
type Service struct {
Store Store
Bus Publisher
Now func() time.Time
type EventAnnouncer interface {
AnnounceEventCreated(context.Context, domain.Event) error
}
func New(store Store, bus Publisher) *Service {
return &Service{Store: store, Bus: bus, Now: func() time.Time { return time.Now().UTC() }}
type Service struct {
Store Store
Bus Publisher
EventAnnouncer EventAnnouncer
AnnouncementTimeout time.Duration
Now func() time.Time
}
func New(store Store, bus Publisher, eventAnnouncer EventAnnouncer) *Service {
return &Service{
Store: store,
Bus: bus,
EventAnnouncer: eventAnnouncer,
AnnouncementTimeout: 3 * time.Second,
Now: func() time.Time { return time.Now().UTC() },
}
}
func NewID() string {
@@ -160,6 +175,11 @@ func (s *Service) CreateEvent(ctx context.Context, actor domain.Account, event d
out, err := s.Store.CreateEvent(ctx, event)
if err == nil {
s.Bus.Publish("events", out)
if s.EventAnnouncer != nil {
announcementCtx, cancel := context.WithTimeout(ctx, s.AnnouncementTimeout)
_ = s.EventAnnouncer.AnnounceEventCreated(announcementCtx, out)
cancel()
}
}
return out, err
}

View File

@@ -0,0 +1,112 @@
package application
import (
"context"
"errors"
"testing"
"time"
"mixmaker/backend/internal/domain"
)
type createEventStore struct {
Store
created domain.Event
err error
}
func (s *createEventStore) CreateEvent(_ context.Context, event domain.Event) (domain.Event, error) {
s.created = event
if s.err != nil {
return domain.Event{}, s.err
}
return event, nil
}
type recordingPublisher struct {
topics []string
}
func (p *recordingPublisher) Publish(topic string, _ any) {
p.topics = append(p.topics, topic)
}
type recordingAnnouncer struct {
events []domain.Event
err error
hasDeadline bool
}
func (a *recordingAnnouncer) AnnounceEventCreated(ctx context.Context, event domain.Event) error {
a.events = append(a.events, event)
_, a.hasDeadline = ctx.Deadline()
return a.err
}
func TestCreateEventAnnouncesAfterPersistence(t *testing.T) {
store := &createEventStore{}
bus := &recordingPublisher{}
announcer := &recordingAnnouncer{}
service := New(store, bus, announcer)
now := time.Date(2026, time.July, 19, 8, 0, 0, 0, time.UTC)
service.Now = func() time.Time { return now }
created, err := service.CreateEvent(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, validEvent(now))
if err != nil {
t.Fatal(err)
}
if len(announcer.events) != 1 {
t.Fatalf("expected one announcement, got %d", len(announcer.events))
}
if announcer.events[0].ID != created.ID || created.ID == "" {
t.Fatalf("announced event ID %q does not match created event ID %q", announcer.events[0].ID, created.ID)
}
if !announcer.hasDeadline {
t.Fatal("announcement context must have a timeout")
}
if len(bus.topics) != 1 || bus.topics[0] != "events" {
t.Fatalf("unexpected published topics: %v", bus.topics)
}
}
func TestCreateEventDoesNotAnnouncePersistenceFailure(t *testing.T) {
persistenceErr := errors.New("database unavailable")
store := &createEventStore{err: persistenceErr}
announcer := &recordingAnnouncer{}
service := New(store, &recordingPublisher{}, announcer)
now := time.Date(2026, time.July, 19, 8, 0, 0, 0, time.UTC)
service.Now = func() time.Time { return now }
_, err := service.CreateEvent(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, validEvent(now))
if !errors.Is(err, persistenceErr) {
t.Fatalf("expected persistence error, got %v", err)
}
if len(announcer.events) != 0 {
t.Fatalf("expected no announcements, got %d", len(announcer.events))
}
}
func TestCreateEventIgnoresAnnouncementFailure(t *testing.T) {
announcementErr := errors.New("discord unavailable")
announcer := &recordingAnnouncer{err: announcementErr}
service := New(&createEventStore{}, &recordingPublisher{}, announcer)
now := time.Date(2026, time.July, 19, 8, 0, 0, 0, time.UTC)
service.Now = func() time.Time { return now }
created, err := service.CreateEvent(context.Background(), domain.Account{ID: "admin", Role: domain.RoleAdmin}, validEvent(now))
if err != nil {
t.Fatalf("announcement failure must not fail event creation: %v", err)
}
if created.ID == "" || len(announcer.events) != 1 {
t.Fatalf("event was not created and announced exactly once: event=%+v announcements=%d", created, len(announcer.events))
}
}
func validEvent(now time.Time) domain.Event {
return domain.Event{
Name: "Sunday Mix",
Description: "Community scrim",
StartsAt: now.Add(24 * time.Hour),
EndsAt: now.Add(28 * time.Hour),
}
}

View File

@@ -393,6 +393,132 @@ func (s *Service) ConfirmRosters(ctx context.Context, actor domain.Account, even
return event, err
}
func (s *Service) InitializeBracket(ctx context.Context, actor domain.Account, eventID string, expectedEventVersion int) (domain.BracketDraft, error) {
if !actor.IsStaff() {
return domain.BracketDraft{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil {
return domain.BracketDraft{}, err
}
roster, err := s.Store.GetRoster(ctx, eventID)
if err != nil {
return domain.BracketDraft{}, err
}
if !roster.Confirmed {
return domain.BracketDraft{}, fmt.Errorf("%w: rosters are not confirmed", domain.ErrConflict)
}
teamIDs := make([]string, len(roster.Teams))
for index := range roster.Teams {
teamIDs[index] = roster.Teams[index].ID
}
draft, err := domain.NewBracketDraft(eventID, teamIDs)
if err != nil {
return domain.BracketDraft{}, err
}
old := event.Version
if err = event.Transition([]domain.EventState{domain.RostersConfirmed}, domain.BracketDraftState, expectedEventVersion); err != nil {
return domain.BracketDraft{}, err
}
if _, err = s.Store.SaveBracketDraft(ctx, *draft, -1); err != nil {
return domain.BracketDraft{}, err
}
event.UpdatedAt = s.Now()
if _, err = s.Store.SaveEventWorkflow(ctx, event, old); err == nil {
s.Bus.Publish("event:"+eventID, event)
s.Bus.Publish("bracket:"+eventID, draft)
}
return *draft, err
}
func (s *Service) ResetBracket(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (domain.BracketDraft, error) {
if !actor.IsStaff() {
return domain.BracketDraft{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil || event.State != domain.BracketDraftState {
if err == nil {
err = fmt.Errorf("%w: bracket is not editable", domain.ErrConflict)
}
return domain.BracketDraft{}, err
}
current, err := s.Store.GetBracketDraft(ctx, eventID)
if err != nil || current.Version != expectedVersion {
if err == nil {
err = domain.ErrConflict
}
return current, err
}
draft, err := domain.NewBracketDraft(eventID, current.TeamIDs)
if err != nil {
return current, err
}
draft.Version = current.Version + 1
out, err := s.Store.SaveBracketDraft(ctx, *draft, current.Version)
if err == nil {
s.Bus.Publish("bracket:"+eventID, out)
}
return out, err
}
func (s *Service) UpdateBracket(ctx context.Context, actor domain.Account, eventID string, matches []domain.BracketMatch, expectedVersion int) (domain.BracketDraft, error) {
if !actor.IsStaff() {
return domain.BracketDraft{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil || event.State != domain.BracketDraftState {
if err == nil {
err = fmt.Errorf("%w: bracket is not editable", domain.ErrConflict)
}
return domain.BracketDraft{}, err
}
draft, err := s.Store.GetBracketDraft(ctx, eventID)
if err != nil || draft.Version != expectedVersion {
if err == nil {
err = domain.ErrConflict
}
return draft, err
}
draft.Matches, draft.Confirmed, draft.Version = matches, false, draft.Version+1
if err = draft.Validate(); err != nil {
return draft, err
}
out, err := s.Store.SaveBracketDraft(ctx, draft, expectedVersion)
if err == nil {
s.Bus.Publish("bracket:"+eventID, out)
}
return out, err
}
func (s *Service) ConfirmBracket(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (domain.BracketDraft, error) {
if !actor.IsStaff() {
return domain.BracketDraft{}, domain.ErrForbidden
}
event, err := s.Store.GetEvent(ctx, eventID)
if err != nil || event.State != domain.BracketDraftState {
if err == nil {
err = fmt.Errorf("%w: bracket is not editable", domain.ErrConflict)
}
return domain.BracketDraft{}, err
}
draft, err := s.Store.GetBracketDraft(ctx, eventID)
if err != nil || draft.Version != expectedVersion {
if err == nil {
err = domain.ErrConflict
}
return draft, err
}
if err = draft.Validate(); err != nil {
return draft, err
}
draft.Confirmed, draft.Version = true, draft.Version+1
out, err := s.Store.SaveBracketDraft(ctx, draft, expectedVersion)
if err == nil {
s.Bus.Publish("bracket:"+eventID, out)
}
return out, err
}
func (s *Service) RevertWorkflowStage(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (domain.Event, error) {
if !actor.IsStaff() {
return domain.Event{}, domain.ErrForbidden
@@ -412,6 +538,8 @@ func (s *Service) RevertWorkflowStage(ctx context.Context, actor domain.Account,
previous = domain.Balancing
case domain.RostersConfirmed:
previous = domain.RostersDraft
case domain.BracketDraftState:
previous = domain.RostersConfirmed
default:
return event, fmt.Errorf("%w: this workflow stage cannot be reverted", domain.ErrConflict)
}
@@ -435,6 +563,9 @@ func (s *Service) RevertWorkflowStage(ctx context.Context, actor domain.Account,
return event, rosterErr
}
}
if event.State == domain.RostersConfirmed && previous == domain.RostersConfirmed {
_ = s.Store.DeleteBracketDraft(ctx, eventID)
}
event.UpdatedAt = s.Now()
event, err = s.Store.SaveEventWorkflow(ctx, event, oldVersion)
if err == nil {
@@ -459,52 +590,50 @@ func (s *Service) StartScrim(ctx context.Context, actor domain.Account, eventID
if !roster.Confirmed {
return ScrimStart{}, fmt.Errorf("%w: rosters are not confirmed", domain.ErrConflict)
}
draft, err := s.Store.GetBracketDraft(ctx, eventID)
if err != nil {
return ScrimStart{}, err
}
if !draft.Confirmed {
return ScrimStart{}, fmt.Errorf("%w: bracket is not confirmed", domain.ErrConflict)
}
if err = draft.Validate(); err != nil {
return ScrimStart{}, err
}
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 {
if err = event.Transition([]domain.EventState{domain.BracketDraftState}, 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
tournament, err := domain.NewGraphTournament(NewID(), eventID, event.Name, draft)
if err != nil {
return ScrimStart{}, err
}
if len(teamIDs) == 2 {
series, createErr := domain.NewSeries(NewID(), eventID, "", [2]string{teamIDs[0], teamIDs[1]}, rules)
for _, ready := range tournament.ReadyMatches() {
series, createErr := domain.NewSeries(ready.ID, eventID, tournament.ID, [2]string{ready.TeamAID, ready.TeamBID}, 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)
for index := range tournament.Matches {
if tournament.Matches[index].ID == ready.ID {
tournament.Matches[index].SeriesID = series.ID
tournament.Matches[index].Series = series
}
}
event.TournamentID = tournament.ID
result.Tournament = tournament
} else {
return ScrimStart{}, fmt.Errorf("%w: scrim needs two or at least four teams", domain.ErrInvalid)
seriesToSave = append(seriesToSave, *series)
if result.Series == nil {
result.Series = series
event.ActiveSeriesID = series.ID
}
}
tournament.BuildRounds()
event.TournamentID = tournament.ID
result.Tournament = tournament
event.UpdatedAt = s.Now()
err = s.Store.StartScrim(ctx, event, oldVersion, seriesToSave, result.Tournament)
if err == nil {
@@ -562,6 +691,9 @@ func (s *Service) RecordSeriesResult(ctx context.Context, actor domain.Account,
if err != nil {
return out, err
}
if len(tournament.Matches) > 0 {
return out, s.advanceGraphTournament(ctx, out, tournament)
}
for round := range tournament.Rounds {
for match := range tournament.Rounds[round] {
if tournament.Rounds[round][match].ID != out.ID {
@@ -610,6 +742,67 @@ func (s *Service) RecordSeriesResult(ctx context.Context, actor domain.Account,
return out, err
}
func (s *Service) advanceGraphTournament(ctx context.Context, completed domain.Series, tournament domain.Tournament) error {
event, err := s.Store.GetEvent(ctx, completed.EventID)
if err != nil {
return err
}
rules, err := s.Store.GetRuleset(ctx, event.RulesetID)
if err != nil {
return err
}
for attempt := 0; attempt < 3; attempt++ {
if err = tournament.ApplySeries(completed); err != nil {
return err
}
for _, ready := range tournament.ReadyMatches() {
series, getErr := s.Store.GetSeries(ctx, ready.ID)
if getErr == domain.ErrNotFound {
created, createErr := domain.NewSeries(ready.ID, completed.EventID, tournament.ID, [2]string{ready.TeamAID, ready.TeamBID}, rules)
if createErr != nil {
return createErr
}
series = *created
if _, createErr = s.Store.SaveSeries(ctx, series); createErr != nil && createErr != domain.ErrConflict {
return createErr
}
} else if getErr != nil {
return getErr
}
for index := range tournament.Matches {
if tournament.Matches[index].ID == ready.ID {
tournament.Matches[index].SeriesID = series.ID
copy := series
tournament.Matches[index].Series = &copy
}
}
}
tournament.BuildRounds()
if _, err = s.Store.SaveTournament(ctx, tournament); err != domain.ErrConflict {
break
}
tournament, err = s.Store.GetTournament(ctx, tournament.ID)
if err != nil {
return err
}
}
if err != nil {
return err
}
s.Bus.Publish("tournament:"+tournament.ID, tournament)
s.Bus.Publish("event:"+completed.EventID, tournament)
if tournament.Complete() {
event, err = s.Store.GetEvent(ctx, completed.EventID)
if err != nil {
return err
}
old := event.Version
event.State, event.Version, event.UpdatedAt = domain.Completed, event.Version+1, s.Now()
_, err = s.Store.SaveEventWorkflow(ctx, event, old)
}
return err
}
func (s *Service) mutateSeries(ctx context.Context, actor domain.Account, player domain.Player, seriesID, actingTeamID string, expectedVersion int, action string, mutation func(*domain.Series, domain.Ruleset) error) (domain.Series, error) {
series, err := s.Store.GetSeries(ctx, seriesID)
if err != nil {

View File

@@ -0,0 +1,262 @@
package domain
import (
"fmt"
"slices"
"sort"
)
type SlotSourceKind string
const (
SlotTeam SlotSourceKind = "Team"
SlotWinner SlotSourceKind = "Winner"
SlotLoser SlotSourceKind = "Loser"
)
type SlotSource struct {
Kind SlotSourceKind `json:"kind"`
TeamID string `json:"teamId,omitempty"`
MatchID string `json:"matchId,omitempty"`
}
type BracketMatch struct {
ID string `json:"id"`
Round int `json:"round"`
Order int `json:"order"`
SlotA SlotSource `json:"slotA"`
SlotB SlotSource `json:"slotB"`
SeriesID string `json:"seriesId,omitempty"`
TeamAID string `json:"teamAId,omitempty"`
TeamBID string `json:"teamBId,omitempty"`
WinnerTeamID string `json:"winnerTeamId,omitempty"`
Series *Series `json:"series,omitempty"`
}
type BracketDraft struct {
EventID string `json:"eventId"`
TeamIDs []string `json:"teamIds"`
Matches []BracketMatch `json:"matches"`
Version int `json:"version"`
Confirmed bool `json:"confirmed"`
}
func NewBracketDraft(eventID string, teamIDs []string) (*BracketDraft, error) {
if eventID == "" || len(teamIDs) < 2 {
return nil, fmt.Errorf("%w: bracket needs at least two teams", ErrInvalid)
}
draft := &BracketDraft{EventID: eventID, TeamIDs: slices.Clone(teamIDs)}
add := func(round, order int, a, b SlotSource) string {
id := fmt.Sprintf("%s-match-%d", eventID, len(draft.Matches)+1)
draft.Matches = append(draft.Matches, BracketMatch{ID: id, Round: round, Order: order, SlotA: a, SlotB: b})
return id
}
team := func(id string) SlotSource { return SlotSource{Kind: SlotTeam, TeamID: id} }
winner := func(id string) SlotSource { return SlotSource{Kind: SlotWinner, MatchID: id} }
loser := func(id string) SlotSource { return SlotSource{Kind: SlotLoser, MatchID: id} }
if len(teamIDs) == 2 {
add(0, 0, team(teamIDs[0]), team(teamIDs[1]))
} else if len(teamIDs) == 3 {
first := add(0, 0, team(teamIDs[0]), team(teamIDs[1]))
second := add(1, 0, loser(first), team(teamIDs[2]))
add(2, 0, winner(first), winner(second))
} else if len(teamIDs)&(len(teamIDs)-1) == 0 {
previous := make([]string, 0, len(teamIDs)/2)
for i := 0; i < len(teamIDs); i += 2 {
previous = append(previous, add(0, i/2, team(teamIDs[i]), team(teamIDs[i+1])))
}
for round := 1; len(previous) > 1; round++ {
next := make([]string, 0, len(previous)/2)
for i := 0; i < len(previous); i += 2 {
next = append(next, add(round, i/2, winner(previous[i]), winner(previous[i+1])))
}
previous = next
}
} else {
previous := add(0, 0, team(teamIDs[0]), team(teamIDs[1]))
for i := 2; i < len(teamIDs); i++ {
previous = add(i-1, 0, winner(previous), team(teamIDs[i]))
}
}
return draft, draft.Validate()
}
func (d BracketDraft) Validate() error {
if d.EventID == "" || len(d.TeamIDs) < 2 || len(d.Matches) == 0 {
return fmt.Errorf("%w: incomplete bracket", ErrInvalid)
}
teams := make(map[string]bool, len(d.TeamIDs))
for _, id := range d.TeamIDs {
if id == "" || teams[id] {
return fmt.Errorf("%w: invalid bracket teams", ErrInvalid)
}
teams[id] = true
}
matches := make(map[string]BracketMatch, len(d.Matches))
positions := make(map[string]bool, len(d.Matches))
maxRound := -1
for _, match := range d.Matches {
position := fmt.Sprintf("%d:%d", match.Round, match.Order)
if match.ID == "" || match.Round < 0 || match.Order < 0 || matches[match.ID].ID != "" || positions[position] {
return fmt.Errorf("%w: invalid bracket match", ErrInvalid)
}
matches[match.ID] = match
positions[position] = true
if match.Round > maxRound {
maxRound = match.Round
}
}
finals := 0
for _, match := range d.Matches {
if match.Round == maxRound {
finals++
}
for _, source := range []SlotSource{match.SlotA, match.SlotB} {
switch source.Kind {
case SlotTeam:
if !teams[source.TeamID] || source.MatchID != "" {
return fmt.Errorf("%w: unknown team source", ErrInvalid)
}
case SlotWinner, SlotLoser:
upstream, ok := matches[source.MatchID]
if !ok || upstream.Round >= match.Round || source.TeamID != "" {
return fmt.Errorf("%w: match source must reference an earlier round", ErrInvalid)
}
default:
return fmt.Errorf("%w: empty bracket slot", ErrInvalid)
}
}
if match.SlotA.Kind == SlotTeam && match.SlotB.Kind == SlotTeam && match.SlotA.TeamID == match.SlotB.TeamID {
return fmt.Errorf("%w: a team cannot play itself", ErrInvalid)
}
}
if finals != 1 {
return fmt.Errorf("%w: the last round must contain exactly one match", ErrInvalid)
}
return nil
}
func NewGraphTournament(id, eventID, name string, draft BracketDraft) (*Tournament, error) {
if err := draft.Validate(); err != nil {
return nil, err
}
t := &Tournament{ID: id, EventID: eventID, Name: name, TeamIDs: slices.Clone(draft.TeamIDs), Matches: slices.Clone(draft.Matches), Version: 0}
for i := range t.Matches {
t.Matches[i].SeriesID, t.Matches[i].TeamAID, t.Matches[i].TeamBID, t.Matches[i].WinnerTeamID, t.Matches[i].Series = "", "", "", "", nil
}
if err := t.Resolve(); err != nil {
return nil, err
}
t.BuildRounds()
return t, nil
}
func (t *Tournament) Resolve() error {
byID := make(map[string]*BracketMatch, len(t.Matches))
for i := range t.Matches {
byID[t.Matches[i].ID] = &t.Matches[i]
}
resolve := func(source SlotSource) string {
if source.Kind == SlotTeam {
return source.TeamID
}
match := byID[source.MatchID]
if match == nil || match.WinnerTeamID == "" {
return ""
}
if source.Kind == SlotWinner {
return match.WinnerTeamID
}
if match.WinnerTeamID == match.TeamAID {
return match.TeamBID
}
return match.TeamAID
}
for i := range t.Matches {
match := &t.Matches[i]
match.TeamAID, match.TeamBID = resolve(match.SlotA), resolve(match.SlotB)
if match.TeamAID != "" && match.TeamAID == match.TeamBID {
return fmt.Errorf("%w: bracket match resolved to the same team", ErrConflict)
}
}
t.BuildRounds()
return nil
}
func (t *Tournament) ApplySeries(series Series) error {
found := false
for i := range t.Matches {
if t.Matches[i].ID == series.ID || t.Matches[i].SeriesID == series.ID {
t.Matches[i].SeriesID = series.ID
t.Matches[i].TeamAID, t.Matches[i].TeamBID = series.TeamAID, series.TeamBID
t.Matches[i].WinnerTeamID = series.WinnerTeamID
copy := series
t.Matches[i].Series = &copy
found = true
break
}
}
if !found {
return ErrNotFound
}
if err := t.Resolve(); err != nil {
return err
}
t.Version++
if t.Complete() {
last := slices.MaxFunc(t.Matches, func(a, b BracketMatch) int {
if a.Round != b.Round {
return a.Round - b.Round
}
return a.Order - b.Order
})
t.WinnerTeamID = last.WinnerTeamID
}
return nil
}
func (t *Tournament) ReadyMatches() []BracketMatch {
out := make([]BracketMatch, 0)
for _, match := range t.Matches {
if match.TeamAID != "" && match.TeamBID != "" && match.SeriesID == "" {
out = append(out, match)
}
}
return out
}
func (t *Tournament) Complete() bool {
if len(t.Matches) == 0 {
return false
}
for _, match := range t.Matches {
if match.WinnerTeamID == "" {
return false
}
}
return true
}
func (t *Tournament) BuildRounds() {
if len(t.Matches) == 0 {
return
}
maxRound := 0
for _, match := range t.Matches {
if match.Round > maxRound {
maxRound = match.Round
}
}
t.Rounds = make([][]Series, maxRound+1)
matches := slices.Clone(t.Matches)
sort.Slice(matches, func(i, j int) bool {
return matches[i].Round < matches[j].Round || matches[i].Round == matches[j].Round && matches[i].Order < matches[j].Order
})
for _, match := range matches {
if match.Series != nil {
t.Rounds[match.Round] = append(t.Rounds[match.Round], *match.Series)
} else {
t.Rounds[match.Round] = append(t.Rounds[match.Round], Series{ID: match.ID, EventID: t.EventID, TournamentID: t.ID, TeamAID: match.TeamAID, TeamBID: match.TeamBID, WinnerTeamID: match.WinnerTeamID, BestOf: 3})
}
}
}

View File

@@ -0,0 +1,51 @@
package domain
import "testing"
func TestThreeTeamBracketResolvesWinnerAndLoserFeeds(t *testing.T) {
draft, err := NewBracketDraft("event", []string{"a", "b", "c"})
if err != nil {
t.Fatal(err)
}
tournament, err := NewGraphTournament("cup", "event", "Cup", *draft)
if err != nil {
t.Fatal(err)
}
ready := tournament.ReadyMatches()
if len(ready) != 1 || ready[0].TeamAID != "a" || ready[0].TeamBID != "b" {
t.Fatalf("unexpected first match: %#v", ready)
}
if err = tournament.ApplySeries(Series{ID: ready[0].ID, TeamAID: "a", TeamBID: "b", WinnerTeamID: "a"}); err != nil {
t.Fatal(err)
}
ready = tournament.ReadyMatches()
if len(ready) != 1 || ready[0].TeamAID != "b" || ready[0].TeamBID != "c" {
t.Fatalf("loser feed was not resolved: %#v", ready)
}
if err = tournament.ApplySeries(Series{ID: ready[0].ID, TeamAID: "b", TeamBID: "c", WinnerTeamID: "c"}); err != nil {
t.Fatal(err)
}
ready = tournament.ReadyMatches()
if len(ready) != 1 || ready[0].TeamAID != "a" || ready[0].TeamBID != "c" {
t.Fatalf("final feed was not resolved: %#v", ready)
}
if err = tournament.ApplySeries(Series{ID: ready[0].ID, TeamAID: "a", TeamBID: "c", WinnerTeamID: "c"}); err != nil {
t.Fatal(err)
}
if !tournament.Complete() || tournament.WinnerTeamID != "c" {
t.Fatalf("unexpected champion: %q", tournament.WinnerTeamID)
}
}
func TestBracketRejectsForwardReferencesAndMultipleFinals(t *testing.T) {
draft := BracketDraft{
EventID: "event", TeamIDs: []string{"a", "b"},
Matches: []BracketMatch{
{ID: "m1", Round: 0, SlotA: SlotSource{Kind: SlotWinner, MatchID: "m2"}, SlotB: SlotSource{Kind: SlotTeam, TeamID: "a"}},
{ID: "m2", Round: 0, Order: 1, SlotA: SlotSource{Kind: SlotTeam, TeamID: "a"}, SlotB: SlotSource{Kind: SlotTeam, TeamID: "b"}},
},
}
if err := draft.Validate(); err == nil {
t.Fatal("expected invalid graph")
}
}

View File

@@ -13,6 +13,7 @@ const (
Balancing EventState = "Balancing"
RostersDraft EventState = "RostersDraft"
RostersConfirmed EventState = "RostersConfirmed"
BracketDraftState EventState = "BracketDraft"
Live EventState = "Live"
Completed EventState = "Completed"
Cancelled EventState = "Cancelled"

View File

@@ -468,12 +468,14 @@ func (s *Series) recalculateWinner() {
}
type Tournament struct {
ID string `json:"id"`
EventID string `json:"eventId"`
Name string `json:"name"`
WinnerTeamID string `json:"winnerTeamId"`
TeamIDs []string `json:"teamIds"`
Rounds [][]Series `json:"rounds"`
ID string `json:"id"`
EventID string `json:"eventId"`
Name string `json:"name"`
WinnerTeamID string `json:"winnerTeamId"`
TeamIDs []string `json:"teamIds"`
Matches []BracketMatch `json:"matches,omitempty"`
Rounds [][]Series `json:"rounds"`
Version int `json:"version"`
}
func NewTournament(id, eventID, name string, teams []string) (*Tournament, error) {

View File

@@ -0,0 +1,26 @@
-- +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', 'BracketDraft', 'Live', 'Completed', 'Cancelled'
));
ALTER TABLE tournaments ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS event_bracket_drafts (
event_id text PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE,
body jsonb NOT NULL,
version integer NOT NULL DEFAULT 0,
confirmed boolean NOT NULL DEFAULT false,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- +mixmaker Down
DROP TABLE IF EXISTS event_bracket_drafts;
ALTER TABLE tournaments DROP COLUMN IF EXISTS version;
UPDATE events SET state = 'RostersConfirmed' WHERE state = 'BracketDraft';
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'
));

View File

@@ -33,6 +33,7 @@ services:
environment:
APP_ENV: production
HTTP_ADDR: :8080
PUBLIC_URL: ${PUBLIC_URL:?PUBLIC_URL is required}
FRONTEND_URL: ${PUBLIC_URL:?PUBLIC_URL is required}
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
SESSION_TTL: ${SESSION_TTL:-168h}
@@ -40,6 +41,9 @@ services:
DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET:?DISCORD_CLIENT_SECRET is required}
DISCORD_REDIRECT_URL: ${DISCORD_REDIRECT_URL:?DISCORD_REDIRECT_URL is required}
ADMIN_DISCORD_IDS: ${ADMIN_DISCORD_IDS:-}
DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN:-}
DISCORD_ANNOUNCEMENT_CHANNEL_ID: ${DISCORD_ANNOUNCEMENT_CHANNEL_ID:-}
DISCORD_ANNOUNCEMENT_LOCALE: ${DISCORD_ANNOUNCEMENT_LOCALE:-ru}
LOG_LEVEL: ${LOG_LEVEL:-info}
depends_on:
migrations:

View File

@@ -19,6 +19,9 @@ Set these as production variables in Coolify:
- `DISCORD_CLIENT_SECRET`
- `DISCORD_REDIRECT_URL=https://mix.example.com/api/auth/discord/callback`
- `ADMIN_DISCORD_IDS` — comma-separated Discord user IDs
- `DISCORD_BOT_TOKEN` — bot token from the Developer Portal; never expose it to the frontend
- `DISCORD_ANNOUNCEMENT_CHANNEL_ID` — numeric ID of the text channel for new mix announcements
- `DISCORD_ANNOUNCEMENT_LOCALE=ru` — announcement language, `ru` or `en`
Do not copy production values into `.env` in the repository.
@@ -31,6 +34,8 @@ In the Discord Developer Portal:
3. Use only the `identify` OAuth scope.
4. Store the client secret only in Coolify.
For event announcements, add the bot to the server with the `bot` scope. In the announcement channel grant only `View Channel`, `Send Messages`, `Embed Links`, and `Mention Everyone`. Copy the channel ID with Discord Developer Mode and store it in `DISCORD_ANNOUNCEMENT_CHANNEL_ID`. The bot token is separate from the OAuth client secret and must also remain only in Coolify. If either the token or channel ID is omitted, announcements stay disabled.
## Storage and backups
PostgreSQL uses the `postgres_data` named volume. Confirm that Coolify recognizes it as persistent storage before accepting users.
@@ -44,7 +49,8 @@ Persistent storage is not a backup. Schedule an encrypted `pg_dump` to storage o
3. Open `/health` through the public domain.
4. Complete Discord login and verify the admin role.
5. Create a test event and RSVP.
6. Restart/redeploy and confirm the event remains.
7. Verify SSE remains connected through the proxy.
6. Confirm the bot posts one announcement with an `@everyone` mention and a working registration button.
7. Restart/redeploy and confirm the event remains.
8. Verify SSE remains connected through the proxy.
Rollback application images only when the database migration is backward-compatible. Otherwise restore the matching database backup.

View File

@@ -21,6 +21,7 @@ import { LanguageSelector } from './i18n'
import { useLanguage } from './i18n-context'
import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks'
import { activeHeroBanMap } from './series-utils'
import { BracketEditor } from './components/bracket-editor/BracketEditor'
type RouterContext = { session: Session | null }
const isStaff = (role?: Session['account']['role']) => role === 'admin' || role === 'moderator'
@@ -86,6 +87,8 @@ function AppShell() {
void client.invalidateQueries({ queryKey: ['series', id] })
} else if (scope === 'tournament') {
void client.invalidateQueries({ queryKey: ['tournament'] })
} else if (scope === 'bracket' && id) {
void client.invalidateQueries({ queryKey: ['bracket-draft', id] })
} else if (scope === 'team') {
void client.invalidateQueries({ queryKey: ['teams'] })
void client.invalidateQueries({ queryKey: ['series'] })
@@ -475,9 +478,20 @@ function WorkflowControl({ eventId }: { eventId: string }) {
const roster = useQuery({
queryKey: ['roster', eventId],
queryFn: () => api.roster(eventId),
enabled: Boolean(event.data && ['RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].includes(event.data.workflowState)),
enabled: Boolean(event.data && ['RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'].includes(event.data.workflowState)),
retry: false,
})
const bracketDraft = useQuery({
queryKey: ['bracket-draft', eventId],
queryFn: () => api.bracketDraft(eventId),
enabled: event.data?.workflowState === 'BracketDraft',
retry: false,
})
const bracketTeams = useQuery({
queryKey: ['teams', eventId],
queryFn: () => api.teams(eventId),
enabled: event.data?.workflowState === 'BracketDraft',
})
const [candidates, setCandidates] = useState<Awaited<ReturnType<typeof api.generateWorkflowBalance>>['candidates']>([])
const [selectedCandidate, setSelectedCandidate] = useState(0)
const refresh = useCallback(() => {
@@ -510,6 +524,29 @@ function WorkflowControl({ eventId }: { eventId: string }) {
onSuccess: refresh,
onError: refresh,
})
const initializeBracket = useMutation({
mutationFn: () => api.initializeBracket(eventId, event.data!.version),
onSuccess: (draft) => {
client.setQueryData(['bracket-draft', eventId], draft)
refresh()
},
onError: refresh,
})
const saveBracket = useMutation({
mutationFn: (matches: Parameters<typeof api.updateBracket>[1]) => api.updateBracket(eventId, matches, bracketDraft.data!.version),
onSuccess: (draft) => client.setQueryData(['bracket-draft', eventId], draft),
onError: () => void bracketDraft.refetch(),
})
const resetBracket = useMutation({
mutationFn: () => api.resetBracket(eventId, bracketDraft.data!.version),
onSuccess: (draft) => client.setQueryData(['bracket-draft', eventId], draft),
onError: () => void bracketDraft.refetch(),
})
const confirmBracket = useMutation({
mutationFn: () => api.confirmBracket(eventId, bracketDraft.data!.version),
onSuccess: (draft) => client.setQueryData(['bracket-draft', eventId], draft),
onError: () => void bracketDraft.refetch(),
})
const start = useMutation({
mutationFn: () => api.startScrim(eventId, event.data!.version),
onSuccess: (result) => {
@@ -527,6 +564,7 @@ function WorkflowControl({ eventId }: { eventId: string }) {
onSuccess: (updated) => {
if (updated.workflowState === 'RegistrationClosed') setCandidates([])
if (updated.workflowState === 'Balancing') client.removeQueries({ queryKey: ['roster', eventId] })
if (updated.workflowState === 'RostersConfirmed') client.removeQueries({ queryKey: ['bracket-draft', eventId] })
refresh()
},
onError: refresh,
@@ -534,11 +572,11 @@ function WorkflowControl({ eventId }: { eventId: string }) {
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 ?? back.error
const error = close.error ?? generate.error ?? select.error ?? confirm.error ?? initializeBracket.error ?? saveBracket.error ?? resetBracket.error ?? confirmBracket.error ?? start.error ?? back.error ?? bracketDraft.error ?? bracketTeams.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)
{(['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'] as const).map((step, index) => {
const activeIndex = ['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'BracketDraft', '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>
@@ -547,9 +585,11 @@ function WorkflowControl({ eventId }: { eventId: string }) {
{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} allowCaptainAssignment={false} />)}</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 && ['RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].includes(state) && <WorkflowRosterEditor eventId={eventId} roster={roster.data} players={players.data} locked={state !== 'RostersDraft'} onChanged={refresh} />}
{roster.data && ['RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'].includes(state) && <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 === 'RostersConfirmed' && <WorkflowAction title="Configure bracket" description="Build the match order and connect winner or loser feeds before starting." button="Open bracket editor" pending={initializeBracket.isPending} onClick={() => initializeBracket.mutate()} />}
{state === 'BracketDraft' && (bracketDraft.isLoading || bracketTeams.isLoading) && <LoadingState label="Loading bracket editor…" />}
{state === 'BracketDraft' && bracketDraft.data && bracketTeams.data && <BracketEditor draft={bracketDraft.data} teams={bracketTeams.data} pending={saveBracket.isPending || resetBracket.isPending || confirmBracket.isPending || start.isPending} error={error?.message} onSave={(matches) => saveBracket.mutate(matches)} onReset={() => resetBracket.mutate()} onConfirm={() => confirmBracket.mutate()} onStart={() => 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>{event.data.tournamentId ? <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link> : event.data.activeSeriesId ? <Link className="button primary" to="/watch/$seriesId" params={{ seriesId: event.data.activeSeriesId }}>Match history</Link> : null}</section>}
{error && <p className="error-note">{error.message}</p>}

View File

@@ -80,6 +80,21 @@ describe('backend DTO adapters', () => {
expect(adaptSeries(series).score).toEqual({ alpha: 0, beta: 1 })
expect(adaptSeries({ ...series, phase: 'CoinTossPending', results: null, maps: null, audit: null }).score).toEqual({ alpha: 0, beta: 0 })
expect(adaptTournament({ id: 't-1', eventId: 'event-1', name: 'Cup', winnerTeamId: '', teamIds: ['a', 'b'], rounds: [[{ ...series, phase: 'MapBan' }]] }).matches[0].status).toBe('live')
const graph = adaptTournament({
id: 'graph-1',
eventId: 'event-1',
name: 'Custom Cup',
winnerTeamId: '',
teamIds: ['a', 'b'],
version: 0,
rounds: [],
matches: [
{ id: 'm1', round: 0, order: 0, slotA: { kind: 'Team', teamId: 'a' }, slotB: { kind: 'Team', teamId: 'b' }, teamAId: 'a', teamBId: 'b', series },
{ id: 'm2', round: 1, order: 0, slotA: { kind: 'Loser', matchId: 'm1' }, slotB: { kind: 'Winner', matchId: 'm1' } },
],
})
expect(graph.matches[0].status).toBe('live')
expect(graph.matches[1]).toMatchObject({ teamAlpha: 'Loser of R1M1', teamBeta: 'Winner of R1M1' })
const heroDraft = adaptSeries({
...series,
phase: 'HeroBan',
@@ -149,4 +164,23 @@ describe('actual backend routes', () => {
body: JSON.stringify({ name: 'Ember Wolves' }),
}))
})
it('updates the versioned bracket graph', async () => {
const draft = {
eventId: 'event-1',
teamIds: ['a', 'b'],
matches: [{ id: 'm1', round: 0, order: 0, slotA: { kind: 'Team' as const, teamId: 'a' }, slotB: { kind: 'Team' as const, teamId: 'b' } }],
version: 2,
confirmed: false,
}
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(draft), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await api.updateBracket(draft.eventId, draft.matches, 1)
expect(fetchMock).toHaveBeenCalledWith('/api/events/event-1/bracket-draft', expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ matches: draft.matches, expectedVersion: 1 }),
}))
})
})

View File

@@ -46,7 +46,7 @@ export interface MixEvent {
activeSeriesId?: string
tournamentId?: string
}
export type EventWorkflowState = 'RegistrationOpen' | 'RegistrationClosed' | 'Balancing' | 'RostersDraft' | 'RostersConfirmed' | 'Live' | 'Completed' | 'Cancelled'
export type EventWorkflowState = 'RegistrationOpen' | 'RegistrationClosed' | 'Balancing' | 'RostersDraft' | 'RostersConfirmed' | 'BracketDraft' | 'Live' | 'Completed' | 'Cancelled'
export interface TeamPlayer { player: Player; assignedRole: PlayerRole }
export interface Team {
id: string
@@ -122,6 +122,27 @@ export interface EventInput {
endsAt: string
registrationDeadline: string
}
export type BracketSlotSource =
| { kind: 'Team'; teamId: string; matchId?: never }
| { kind: 'Winner' | 'Loser'; matchId: string; teamId?: never }
export interface BracketDraftMatch {
id: string
round: number
order: number
slotA: BracketSlotSource
slotB: BracketSlotSource
seriesId?: string
teamAId?: string
teamBId?: string
winnerTeamId?: string
}
export interface BracketDraft {
eventId: string
teamIds: string[]
matches: BracketDraftMatch[]
version: number
confirmed: boolean
}
// Raw backend DTOs mirror Go's current camelCase JSON tags exactly.
export interface RawAccount {
@@ -174,7 +195,7 @@ export interface RawSeries {
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[][]
teamIds: string[]; rounds: RawSeries[][]; matches?: Array<BracketDraftMatch & { series?: RawSeries }>; version?: number
}
export interface RawRealtimeEvent { topic: string; data: unknown }
@@ -391,6 +412,37 @@ export function adaptSeries(raw: RawSeries, teams: Team[] = []): Series {
}
export function adaptTournament(raw: RawTournament, teams: Team[] = []): Bracket {
const names = new Map(teams.map((team) => [team.id, team.name]))
if (raw.matches?.length) {
const maxRound = Math.max(...raw.matches.map((match) => match.round))
const sourceName = (source: BracketSlotSource) => {
if (source.kind === 'Team') return names.get(source.teamId) ?? source.teamId
const upstream = raw.matches?.find((match) => match.id === source.matchId)
return `${source.kind} of ${upstream ? `R${upstream.round + 1}M${upstream.order + 1}` : 'match'}`
}
return {
id: raw.id,
title: raw.name,
rounds: Array.from({ length: maxRound + 1 }, (_, index) => index === maxRound ? 'Grand Final' : index === maxRound - 1 ? 'Semifinals' : `Round ${index + 1}`),
matches: raw.matches.map((match) => {
const adapted = match.series ? adaptSeries(match.series, teams) : undefined
return {
id: match.id,
round: match.round,
slot: match.order,
label: `Match ${match.order + 1}`,
teamAlpha: names.get(match.teamAId ?? '') ?? match.teamAId ?? sourceName(match.slotA),
teamBeta: names.get(match.teamBId ?? '') ?? match.teamBId ?? sourceName(match.slotB),
teamAlphaId: match.teamAId,
teamBetaId: match.teamBId,
scoreAlpha: adapted?.score.alpha,
scoreBeta: adapted?.score.beta,
winner: match.winnerTeamId ? (match.winnerTeamId === match.teamAId ? 'alpha' : 'beta') : undefined,
seriesId: match.seriesId ?? match.series?.id,
status: match.winnerTeamId ? 'completed' : match.series?.phase ? 'live' : match.teamAId && match.teamBId ? 'ready' : 'pending',
}
}),
}
}
return {
id: raw.id,
title: raw.name,
@@ -599,6 +651,15 @@ export const api = {
method: 'POST',
body: JSON.stringify({ expectedVersion, expectedRosterVersion }),
}),
bracketDraft: (eventId: string) => request<BracketDraft>(`/events/${eventId}/bracket-draft`),
initializeBracket: (eventId: string, expectedVersion: number) =>
request<BracketDraft>(`/events/${eventId}/bracket-draft/initialize`, { method: 'POST', body: JSON.stringify({ expectedVersion }) }),
updateBracket: (eventId: string, matches: BracketDraftMatch[], expectedVersion: number) =>
request<BracketDraft>(`/events/${eventId}/bracket-draft`, { method: 'PUT', body: JSON.stringify({ matches, expectedVersion }) }),
resetBracket: (eventId: string, expectedVersion: number) =>
request<BracketDraft>(`/events/${eventId}/bracket-draft/reset`, { method: 'POST', body: JSON.stringify({ expectedVersion }) }),
confirmBracket: (eventId: string, expectedVersion: number) =>
request<BracketDraft>(`/events/${eventId}/bracket-draft/confirm`, { method: 'POST', body: JSON.stringify({ expectedVersion }) }),
startScrim: async (eventId: string, expectedVersion: number) => {
const result = await request<{ event: RawEvent; series?: RawSeries; tournament?: RawTournament }>(`/events/${eventId}/start`, {
method: 'POST',

View File

@@ -1,6 +1,7 @@
import type {
BalanceCandidate,
Bracket,
BracketDraft,
MixEvent,
Player,
Registration,
@@ -294,3 +295,15 @@ export const demoBracket: Bracket = {
{ id: 'm3', round: 1, slot: 0, label: 'Grand Final', teamBeta: 'Neon Foxes', status: 'pending' },
],
}
export const demoBracketDraft: BracketDraft = {
eventId: 'event-3',
teamIds: ['team-alpha', 'team-beta', 'team-neon'],
version: 2,
confirmed: false,
matches: [
{ id: 'draft-m1', round: 0, order: 0, slotA: { kind: 'Team', teamId: 'team-alpha' }, slotB: { kind: 'Team', teamId: 'team-beta' } },
{ id: 'draft-m2', round: 1, order: 0, slotA: { kind: 'Loser', matchId: 'draft-m1' }, slotB: { kind: 'Team', teamId: 'team-neon' } },
{ id: 'draft-m3', round: 2, order: 0, slotA: { kind: 'Winner', matchId: 'draft-m1' }, slotB: { kind: 'Winner', matchId: 'draft-m2' } },
],
}

View File

@@ -0,0 +1,42 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { demoBalanceCandidates, demoBracketDraft } from '../../api/demo'
import { BracketEditor } from './BracketEditor'
describe('BracketEditor', () => {
afterEach(cleanup)
it('adds horizontal rounds and supports tap-to-place sources', () => {
const onSave = vi.fn()
render(<BracketEditor
draft={demoBracketDraft}
teams={demoBalanceCandidates[0].teams}
pending={false}
onSave={onSave}
onReset={vi.fn()}
onConfirm={vi.fn()}
onStart={vi.fn()}
/>)
fireEvent.click(screen.getByRole('button', { name: /add round/i }))
expect(onSave).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ round: 3 })]))
fireEvent.click(screen.getByRole('button', { name: demoBalanceCandidates[0].teams[0].name }))
fireEvent.click(screen.getAllByRole('button', { name: /team a source/i })[1])
expect(onSave).toHaveBeenLastCalledWith(expect.arrayContaining([
expect.objectContaining({ id: 'draft-m2', slotA: { kind: 'Team', teamId: demoBalanceCandidates[0].teams[0].id } }),
]))
})
it('keeps start locked until the graph is confirmed', () => {
render(<BracketEditor
draft={demoBracketDraft}
teams={demoBalanceCandidates[0].teams}
pending={false}
onSave={vi.fn()}
onReset={vi.fn()}
onConfirm={vi.fn()}
onStart={vi.fn()}
/>)
expect(screen.getByRole('button', { name: /start scrim/i })).toBeDisabled()
})
})

View File

@@ -0,0 +1,93 @@
import { useState, type DragEvent } from 'react'
import { Check, ChevronRight, Plus, RefreshCw, Trash2, Trophy } from 'lucide-react'
import type { BracketDraft, BracketDraftMatch, BracketSlotSource, Team } from '../../api/client'
const dragType = 'application/x-mixmaker-bracket-source'
function sourceLabel(source: BracketSlotSource, teams: Map<string, string>, matches: Map<string, BracketDraftMatch>) {
if (source.kind === 'Team') return teams.get(source.teamId) ?? 'Empty slot'
const match = matches.get(source.matchId)
return `${source.kind} of ${match ? `R${match.round + 1}M${match.order + 1}` : 'unknown match'}`
}
function readSource(event: DragEvent): BracketSlotSource | undefined {
try {
return JSON.parse(event.dataTransfer.getData(dragType)) as BracketSlotSource
} catch {
return undefined
}
}
export function BracketEditor({
draft, teams, pending, error, onSave, onReset, onConfirm, onStart,
}: {
draft: BracketDraft
teams: Team[]
pending: boolean
error?: string
onSave: (matches: BracketDraftMatch[]) => void
onReset: () => void
onConfirm: () => void
onStart: () => void
}) {
const [selectedSource, setSelectedSource] = useState<BracketSlotSource>()
const teamNames = new Map(teams.map((team) => [team.id, team.name]))
const byID = new Map(draft.matches.map((match) => [match.id, match]))
const maxRound = Math.max(0, ...draft.matches.map((match) => match.round))
const rounds = Array.from({ length: maxRound + 1 }, (_, round) => draft.matches.filter((match) => match.round === round).sort((a, b) => a.order - b.order))
const choose = (source: BracketSlotSource) => setSelectedSource(source)
const startDrag = (event: DragEvent, source: BracketSlotSource) => {
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData(dragType, JSON.stringify(source))
}
const place = (matchID: string, slot: 'slotA' | 'slotB', source?: BracketSlotSource) => {
const value = source ?? selectedSource
if (!value || pending) return
onSave(draft.matches.map((match) => match.id === matchID ? { ...match, [slot]: value, seriesId: undefined, teamAId: undefined, teamBId: undefined, winnerTeamId: undefined } : match))
setSelectedSource(undefined)
}
const addMatch = (round: number) => {
const order = rounds[round]?.length ?? 0
onSave([...draft.matches, {
id: `${draft.eventId}-custom-${crypto.randomUUID()}`,
round,
order,
slotA: { kind: 'Team', teamId: '' },
slotB: { kind: 'Team', teamId: '' },
}])
}
const removeMatch = (id: string) => {
const remaining = draft.matches.filter((match) => match.id !== id).map((match) => ({
...match,
slotA: match.slotA.kind !== 'Team' && match.slotA.matchId === id ? { kind: 'Team' as const, teamId: '' } : match.slotA,
slotB: match.slotB.kind !== 'Team' && match.slotB.matchId === id ? { kind: 'Team' as const, teamId: '' } : match.slotB,
}))
const byRound = new Map<number, BracketDraftMatch[]>()
for (const match of remaining) byRound.set(match.round, [...(byRound.get(match.round) ?? []), match])
onSave(remaining.map((match) => ({
...match,
order: [...(byRound.get(match.round) ?? [])].sort((a, b) => a.order - b.order).findIndex((candidate) => candidate.id === match.id),
})))
}
const addRound = () => addMatch(maxRound + 1)
return <section className="bracket-editor">
<div className="bracket-editor-toolbar">
<div><span className="eyebrow">Bracket editor</span><h2>Build the match graph</h2><p>Drag a team, winner, or loser into either match slot.</p></div>
<div><button className="button secondary" disabled={pending} onClick={onReset}><RefreshCw />Reset template</button><button className="button secondary" disabled={pending} onClick={addRound}><Plus />Add round</button></div>
</div>
<div className="bracket-source-palette">
<div><strong>Teams</strong>{teams.map((team) => <button type="button" draggable onDragStart={(event) => startDrag(event, { kind: 'Team', teamId: team.id })} className={selectedSource?.kind === 'Team' && selectedSource.teamId === team.id ? 'selected' : ''} key={team.id} onClick={() => choose({ kind: 'Team', teamId: team.id })}>{team.name}</button>)}</div>
<div><strong>Dynamic feeds</strong>{draft.matches.map((match) => <span key={match.id}><button type="button" draggable onDragStart={(event) => startDrag(event, { kind: 'Winner', matchId: match.id })} onClick={() => choose({ kind: 'Winner', matchId: match.id })}><Trophy />Winner R{match.round + 1}M{match.order + 1}</button><button type="button" draggable onDragStart={(event) => startDrag(event, { kind: 'Loser', matchId: match.id })} onClick={() => choose({ kind: 'Loser', matchId: match.id })}>Loser R{match.round + 1}M{match.order + 1}</button></span>)}</div>
</div>
<div className="bracket-editor-scroll">
{rounds.map((matches, round) => <section className="bracket-editor-round" key={round}><header><div><span>Round {round + 1}</span><h3>{round === maxRound ? 'Final column' : `Stage ${round + 1}`}</h3></div><button className="icon-button" disabled={pending} aria-label={`Add match to round ${round + 1}`} onClick={() => addMatch(round)}><Plus /></button></header>{matches.map((match) => <article className="bracket-editor-match" key={match.id}><div className="match-label"><span>R{round + 1}M{match.order + 1}</span><button className="icon-button" aria-label={`Delete R${round + 1}M${match.order + 1}`} disabled={pending || draft.matches.length === 1} onClick={() => removeMatch(match.id)}><Trash2 /></button></div>{(['slotA', 'slotB'] as const).map((slot) => <button type="button" className={`bracket-slot-drop ${match[slot].kind === 'Team' && !match[slot].teamId ? 'empty' : ''}`} key={slot} onDragOver={(event) => event.preventDefault()} onDrop={(event) => place(match.id, slot, readSource(event))} onClick={() => place(match.id, slot)}><small>{slot === 'slotA' ? 'Team A source' : 'Team B source'}</small><strong>{sourceLabel(match[slot], teamNames, byID)}</strong></button>)}</article>)}</section>)}
</div>
<div className="bracket-editor-footer"><div>{selectedSource && <p>Selected: <strong>{sourceLabel(selectedSource, teamNames, byID)}</strong>. Tap a slot to place it.</p>}{error && <p className="error-note">{error}</p>}</div><div>{draft.confirmed ? <BadgeConfirmed /> : <button className="button secondary" disabled={pending} onClick={onConfirm}><Check />Confirm bracket</button>}<button className="button primary" disabled={pending || !draft.confirmed} onClick={onStart}>Start scrim <ChevronRight /></button></div></div>
</section>
}
function BadgeConfirmed() {
return <span className="bracket-confirmed"><Check />Bracket confirmed</span>
}

View File

@@ -249,6 +249,24 @@ const translations: Record<string, string> = {
'Confirm rosters': 'Подтвердить составы',
'Every team must have a valid 1/2/2 lineup and a captain.': 'В каждой команде должен быть состав 1/2/2 и капитан.',
'Start scrim': 'Начать скрим',
'Bracket Draft': 'Редактор сетки',
'Bracket editor': 'Редактор сетки',
'Configure bracket': 'Настроить сетку',
'Build the match order and connect winner or loser feeds before starting.': 'Настройте порядок матчей и переходы победителей или проигравших.',
'Open bracket editor': 'Открыть редактор сетки',
'Loading bracket editor…': 'Загрузка редактора сетки…',
'Build the match graph': 'Соберите граф матчей',
'Drag a team, winner, or loser into either match slot.': 'Перетащите команду, победителя или проигравшего в слот матча.',
'Reset template': 'Сбросить шаблон',
'Add round': 'Добавить раунд',
'Teams': 'Команды',
'Dynamic feeds': 'Динамические переходы',
'Final column': 'Финальная колонка',
'Team A source': 'Источник команды A',
'Team B source': 'Источник команды B',
'Empty slot': 'Пустой слот',
'Confirm bracket': 'Подтвердить сетку',
'Bracket confirmed': 'Сетка подтверждена',
'This atomically creates the Bo3 series or tournament bracket.': 'Серия Bo3 или турнирная сетка будут созданы атомарно.',
'Scrim is live': 'Скрим начался',
'Coin toss, bans, and map results are now available.': 'Теперь доступны жеребьёвка, баны и результаты карт.',

View File

@@ -232,7 +232,7 @@ textarea { min-height: 85px; resize: vertical; }
.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 { padding: 16px; display: grid; grid-template-columns: repeat(8,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; }
@@ -251,6 +251,7 @@ textarea { min-height: 85px; resize: vertical; }
.roster-reserve > div:first-child { display: flex; gap: 9px; align-items: center; margin-bottom: 13px; }.roster-reserve > div:first-child svg { width: 17px; color: var(--orange); }.roster-reserve span { display: grid; }.roster-reserve small,.reserve-list p { color: var(--muted); font-size: 9px; }
.reserve-list { display: grid; gap: 7px; }.reserve-list button { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); color: var(--text); cursor: grab; text-align: left; }.reserve-list button:active { cursor: grabbing; }.mini-avatar { width: 25px; height: 25px; display: grid !important; place-items: center; border-radius: 5px; background: #253728; color: var(--green); font-size: 8px; }
.moderator-panel { padding: 22px; }.moderator-panel > .section-title { margin-top: 0; }.moderator-list { display: grid; gap: 7px; }.moderator-list > div { min-height: 58px; display: grid; grid-template-columns: 34px minmax(0,1fr) auto; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: #101319; }.moderator-list > div > span:nth-child(2) { display: grid; }.moderator-list small { color: var(--muted); }.moderator-toggle { display: flex; align-items: center; gap: 8px; color: var(--muted); cursor: pointer; }.moderator-toggle input { accent-color: var(--orange); }
.bracket-editor { display: grid; gap: 12px; padding: 20px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }.bracket-editor-toolbar,.bracket-editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; }.bracket-editor-toolbar h2 { margin: 5px 0; }.bracket-editor-toolbar p,.bracket-editor-footer p { color: var(--muted); font-size: 10px; }.bracket-editor-toolbar > div:last-child,.bracket-editor-footer > div:last-child { display: flex; align-items: center; gap: 8px; }.bracket-source-palette { display: grid; grid-template-columns: minmax(220px,1fr) 2fr; gap: 10px; padding: 12px; border: 1px dashed #3d454f; border-radius: 9px; background: #0d1014; }.bracket-source-palette > div { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }.bracket-source-palette > div > strong { width: 100%; color: var(--muted); font-size: 9px; text-transform: uppercase; }.bracket-source-palette button { padding: 7px 9px; display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--border); border-radius: 6px; background: #151920; color: var(--text); cursor: grab; font-size: 9px; }.bracket-source-palette button.selected { border-color: var(--orange); background: var(--orange-soft); }.bracket-source-palette button svg { width: 12px; }.bracket-source-palette span { display: contents; }.bracket-editor-scroll { min-height: 390px; padding: 20px; display: flex; align-items: stretch; gap: 70px; overflow-x: auto; border: 1px solid var(--border); border-radius: 9px; background: radial-gradient(circle at 50% 50%,#171b22,transparent 55%),#0d1014; }.bracket-editor-round { min-width: 280px; display: flex; flex-direction: column; gap: 18px; }.bracket-editor-round > header { display: flex; align-items: center; justify-content: space-between; }.bracket-editor-round > header span { color: var(--orange); font-size: 8px; text-transform: uppercase; }.bracket-editor-round h3 { margin-top: 4px; font-size: 16px; }.bracket-editor-match { border: 1px solid var(--border); border-radius: 8px; background: var(--surface); position: relative; }.bracket-editor-match::after { content: ""; width: 70px; height: 1px; position: absolute; right: -71px; top: 50%; background: #303640; }.bracket-editor-round:last-child .bracket-editor-match::after { display: none; }.bracket-editor-match .match-label button { width: 24px; height: 24px; }.bracket-slot-drop { width: 100%; min-height: 54px; padding: 9px 12px; display: flex; flex-direction: column; align-items: flex-start; justify-content: center; gap: 4px; border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--text); text-align: left; }.bracket-slot-drop:last-child { border-bottom: 0; }.bracket-slot-drop small { color: var(--muted); font-size: 8px; }.bracket-slot-drop.empty { border: 1px dashed #65452e; background: rgba(255,122,26,.04); }.bracket-confirmed { display: inline-flex; align-items: center; gap: 5px; color: var(--green); font-size: 10px; }.bracket-confirmed svg { width: 15px; }
.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; }
.tournament-strip { margin-bottom: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 10px; background: #0d1014; }.tournament-strip > header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }.tournament-strip > header span,.tournament-strip > header a { display: flex; align-items: center; gap: 6px; font-size: 10px; }.tournament-strip > header svg { width: 15px; color: var(--orange); }.tournament-strip-body { display: grid; grid-template-columns: 34px minmax(0,1fr) 34px; align-items: center; gap: 8px; }.mini-bracket { display: flex; gap: 12px; overflow-x: auto; padding: 2px; }.mini-bracket > div { min-width: 175px; display: grid; gap: 5px; }.mini-bracket small { color: var(--muted); text-transform: uppercase; font-size: 8px; }.mini-match { display: grid; gap: 3px; padding: 7px 9px; border: 1px solid var(--border); border-radius: 6px; color: var(--muted); font-size: 9px; }.mini-match span { display: flex; justify-content: space-between; gap: 8px; }.mini-match.current { border-color: var(--orange); background: var(--orange-soft); color: var(--text); }.mini-match.live:not(.current) { border-color: #285c43; }.match-arrow { width: 34px; height: 44px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); color: var(--text); }.match-arrow.disabled { opacity: .3; }.tournament-strip > p { margin: 8px 42px 0; color: var(--muted); font-size: 9px; }
@@ -319,6 +320,7 @@ textarea { min-height: 85px; resize: vertical; }
.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; }
.team-name-controls { grid-template-columns: 1fr; }.team-name-editor { align-items: stretch; flex-direction: column; }
.turn-header { padding: 20px; }.turn-header h2 { font-size: 23px; }.hero-filter-tools { padding: 14px 14px 0; }.draft-options { grid-template-columns: repeat(2,1fr); padding: 14px; }.turn-footer { align-items: stretch; flex-direction: column; }.turn-footer .button { width: 100%; }
.bracket-editor { padding: 14px; }.bracket-editor-toolbar,.bracket-editor-footer { align-items: stretch; flex-direction: column; }.bracket-editor-toolbar > div:last-child,.bracket-editor-footer > div:last-child { width: 100%; }.bracket-editor-toolbar .button,.bracket-editor-footer .button { flex: 1; }.bracket-source-palette { grid-template-columns: 1fr; }.bracket-editor-scroll { padding: 14px; gap: 45px; }.bracket-editor-round { min-width: 245px; }.bracket-editor-match::after { width: 45px; right: -46px; }
.result-form { grid-template-columns: 1fr; }
.ban-display { grid-template-columns: repeat(2,1fr); }.spectator-focus { padding: 24px 14px; }.spectator-grid { grid-template-columns: 1fr; }
.bracket-scroll { padding: 25px; gap: 70px; }.bracket-match::after { width: 70px; right: -71px; }

View File

@@ -2,10 +2,11 @@
## Текущее состояние
Собран полный вертикальный production-пайплайн скрима: закрытие регистрации → versioned балансировка → ручная правка и подтверждение составов 1/2/2 → капитаны → атомарный старт Bo3 или турнира → жеребьёвка → баны карт → баны героев → результаты и завершение. Состояние события, roster draft и серии сохраняется в PostgreSQL с optimistic locking, аудитом и глобальной SSE-синхронизацией без ручного обновления страницы.
Собран полный вертикальный production-пайплайн скрима: закрытие регистрации → versioned балансировка → ручная правка и подтверждение составов 1/2/2 → капитаны → drag-and-drop редактор произвольного графа матчей → атомарный старт готовых Bo3 → жеребьёвка → баны карт → баны героев → результаты и динамическое разрешение Winner/Loser-переходов. Состояние события, roster draft, bracket draft и серии сохраняется в PostgreSQL с optimistic locking, аудитом и глобальной SSE-синхронизацией.
Admin UI содержит пошаговый workflow и roster editor с same-role swap, резервом и запуском. Live, spectator и bracket используют реальные API-команды и realtime refresh; участник турнира автоматически открывает собственный матч, остальные попадают в spectator mode, а актуальная сетка и соседние параллельные матчи доступны прямо над серией. Demo fixtures остались только локальным showcase.
Добавлена роль Moderator: она имеет все операционные права Admin, но управление списком модераторов доступно только Admin. До состояния Live workflow можно откатить на один этап назад.
Добавлен опциональный Discord-анонс нового микса: после успешного создания Event backend через Bot REST API публикует локализованный embed с `@everyone` и кнопкой регистрации. Ошибка Discord логируется, но не отменяет создание события; без bot token и channel ID интеграция отключена.
## Подтверждённые требования
@@ -31,6 +32,7 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
- PostgreSQL в том же Compose-стеке с persistent volume;
- мобильный и десктопный интерфейс.
- карточки отменённых событий отображаются красными, завершённых — зелёными, активных — розовыми с меткой LIVE.
- новый микс может автоматически анонсироваться в настроенном Discord-канале со ссылкой на регистрацию.
## Исходный регламент
@@ -61,7 +63,7 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
- балансировщик автоматически назначает роли и собирает команды 1/2/2;
- предпочтения ролей, союзников и avoid-списки учитываются как мягкие штрафы после требований баланса;
- участники сверх полного состава остаются в резерве;
- первая турнирная сетка — single elimination;
- сетка редактируется как последовательный граф Team / Winner / Loser; последний матч определяет общего победителя;
- игрок отмечается на микс через календарь;
- Admin может изменить RSVP за игрока, а действие сохраняет автора;
- капитан назначается отдельно для каждой команды и должен входить в её состав;
@@ -77,6 +79,6 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
## Ближайший следующий шаг
Проверить миграцию `006_event_workflow.sql` и полный сценарий на staging с реальной PostgreSQL (`TEST_DATABASE_URL`), затем провести первый пробный микс через production UI.
Проверить миграцию `006_event_workflow.sql` и полный сценарий на staging с реальной PostgreSQL (`TEST_DATABASE_URL`), проверить тестовый Discord-анонс с production bot token/channel ID, затем провести первый пробный микс через production UI.
Публичная вкладка FAQ описывает регистрацию, балансировку, полномочия капитанов, выбор карт, баны героев и подсчёт Bo3.

View File

@@ -88,7 +88,7 @@ Application-слой управляет единым versioned workflow собы
Ручное редактирование работает с серверным roster draft. Backend разрешает обмен только между одинаковыми ролевыми слотами и замену слота игроком из резерва, после чего пересчитывает средние рейтинги и метрики. Подтверждение требует полного состава 1/2/2, уникальных игроков и капитана внутри каждой команды.
`start-scrim` атомарно блокирует составы, создаёт одну или несколько Bo3-серий и первый draft state. Для двух команд создаётся одиночная серия; для четырёх и более подтверждённых команд создаётся single-elimination турнир. Tournament read-model гидратируется свежими версиями всех сохранённых серий, поэтому параллельные матчи одного раунда независимо обновляют счёт и фазу в общей сетке. Умный event-level live-вход направляет участника в его матч, капитана/staff — к доступным командам, а остальных — в spectator mode; над серией остаются компактная сетка и навигация между активными матчами раунда.
После подтверждения составов workflow переходит в versioned `BracketDraft`. Staff собирает DAG матчей из источников Team / Winner / Loser; ссылки разрешены только на предыдущие колонки, а в последней колонке должен быть один финальный матч. `start-scrim` материализует все готовые Bo3, а завершение серии разрешает зависимости и атомарно создаёт следующие матчи. Поэтому одна колонка может содержать параллельные серии, а три команды могут играть последовательную ротацию через проигравшего. Tournament read-model гидратируется свежими версиями Series. Умный event-level live-вход направляет участника в его матч, капитана/staff — к доступным командам, а остальных — в spectator mode.
### Драфт как конечный автомат
@@ -112,6 +112,12 @@ Application-слой управляет единым versioned workflow собы
Один глобальный authenticated SSE-канал подписывается на wildcard topic и адресно инвалидирует TanStack Query-кэш для событий, RSVP, составов, серий, турниров и игроков. Поэтому новые регистрации, участники, жеребьёвка, баны, результаты и сетка появляются без перезагрузки. WebSocket не требуется: команды остаются обычными HTTP mutations, а серверные изменения передаются клиентам однонаправленно через SSE. Nginx отключает buffering и держит соединение открытым.
### Discord-уведомления
После успешного создания события application-слой вызывает consumer-owned `EventAnnouncer` port. Discord REST adapter публикует в настроенный текстовый канал локализованный RU/EN embed с `@everyone`, временем начала и ссылкой на `/events/{eventId}`. Внешний вызов имеет короткий timeout и выполняется только после сохранения события: ошибка Discord логируется, но не откатывает созданный микс и не превращает успешную команду в HTTP 500.
Интеграция опциональна и отключена, если bot token и channel ID не заданы. Bot token доступен только API-контейнеру; OAuth client secret по-прежнему используется исключительно для входа. Для анонсов не нужны Gateway intents или постоянно открытое Gateway-соединение.
### Капитаны
Капитан — назначение внутри конкретной команды, а не глобальная роль аккаунта. Admin может назначить или заменить капитана только участником этой команды. Только текущий капитан выполняет командные действия драфта; Admin имеет аварийное право выполнить действие с обязательной записью в аудит.

View File

@@ -20,6 +20,10 @@
Отметка игрока в календаре со статусом Going, Maybe или NotGoing. Игрок меняет собственный статус, а Admin может изменить его за игрока. Регистрация хранит автора и время последнего изменения, чтобы интерфейс явно показывал административное действие. В момент закрытия регистрации Admin подтверждает список участников; только Going-игроки по умолчанию попадают в балансировку.
### Discord-анонс события
Созданный микс проецируется во внешний Discord-канал как информационный анонс со ссылкой на страницу Event. Анонс не является источником состояния регистрации: RSVP, доступность события и все переходы workflow по-прежнему определяет backend Mixmaker. Недоступность Discord не отменяет валидное создание Event.
### Жизненный цикл события
Событие проходит серверно контролируемые состояния:
@@ -29,9 +33,10 @@
3. `Balancing` — создаются варианты команд и резерв;
4. `RostersDraft` — Admin выбирает вариант, меняет игроков одной роли местами и заменяет их резервными;
5. `RostersConfirmed` — составы 1/2/2 и капитаны проверены и заблокированы;
6. `Live` — создана серия, разрешены только действия текущего шага жеребьёвки, банов или результата;
7. `Completed` — серия или турнир завершены;
8. `Cancelled`Admin отменил событие; оно остаётся в календаре, но игровые действия заблокированы.
6. `BracketDraft` — staff задаёт порядок Bo3 и источники Team / Winner / Loser для слотов;
7. `Live` — созданы готовые серии, разрешены только действия текущего шага жеребьёвки, банов или результата;
8. `Completed`выполнены все запланированные матчи;
9. `Cancelled` — Admin отменил событие; оно остаётся в календаре, но игровые действия заблокированы.
Переходы выполняются отдельными командами API с проверкой ожидаемой версии. До запуска скрима staff-пользователь может вернуться на один этап назад: черновик удаляется при возврате из `RostersDraft`, а состав снова разблокируется при возврате из `RostersConfirmed`. Возврат из `Live` и `Completed` запрещён. Нельзя начать скрим без минимум двух полных команд, валидного состава 1/2/2 и капитана в каждой команде. После `RostersConfirmed` обычная перестановка запрещена. Аварийная замена во время `Live` доступна staff-пользователю, должна сохранять ролевой состав и записывается в аудит. Полное удаление события является отдельной staff-командой и каскадно удаляет его RSVP, составы, roster draft, серии, турнир и event-scoped аудит.
@@ -73,7 +78,7 @@
### Tournament
Формат и состояние турнирной сетки, участники, раунды, пары и продвижение победителей.
Versioned граф матчей, в котором каждый слот ссылается на конкретную Team либо Winner/Loser матча из предыдущей колонки. Готовые зависимости материализуются в отдельные Bo3-серии автоматически; несколько готовых матчей могут идти параллельно. Последняя колонка содержит один матч, победитель которого становится общим победителем. Для трёх команд стартовый шаблон проводит `AB`, затем `Loser(M1)C`, затем финал победителей первых двух матчей.
## Правила текущего регламента

View File

@@ -9,7 +9,7 @@
- [x] дать Admin обменивать игроков одинаковой роли и заменять их резервными;
- [x] пересчитывать показатели после каждой ручной правки;
- [x] подтвердить составы 1/2/2 и капитанов;
- [x] запускать одиночную Bo3-серию для двух команд или single-elimination турнир для нескольких;
- [x] редактировать произвольный граф Bo3 через Team / Winner / Loser и запускать все готовые пары;
- [x] связать жеребьёвку, map draft, hero draft и результаты карт единым versioned FSM;
- [x] блокировать обычное изменение составов после старта;
- [x] разрешить аудируемую аварийную замену Admin;
@@ -69,8 +69,8 @@
## Этап 4 — турнир
- single-elimination сетка;
- автоматическое создание следующих пар;
- versioned drag-and-drop редактор произвольной сетки;
- автоматическое создание следующих пар по Winner/Loser-зависимостям;
- экран трансляции/зрителя;
- история результатов игрового вечера.
@@ -79,7 +79,9 @@
- пресеты регламентов;
- статистика игроков и команд;
- повторная жеребьёвка с защитой от одинаковых составов;
- Discord-уведомления;
- [x] Discord-анонс нового микса с `@everyone` и ссылкой на регистрацию;
- обновление анонса, напоминания, публикация составов и результатов;
- синхронизация временных Discord-ролей и командных каналов;
- импорт данных и резервное копирование.
## Идеи после MVP

View File

@@ -246,6 +246,47 @@ paths:
responses:
"200": { description: Live tournament bracket, content: { application/json: { schema: { $ref: "#/components/schemas/Tournament" } } } }
"404": { $ref: "#/components/responses/NotFound" }
/api/events/{eventId}/bracket-draft:
get:
tags: [Tournaments]
operationId: getBracketDraft
parameters: [{ $ref: "#/components/parameters/EventId" }]
responses:
"200": { description: Editable bracket graph, content: { application/json: { schema: { $ref: "#/components/schemas/BracketDraft" } } } }
put:
tags: [Tournaments]
operationId: updateBracketDraft
parameters: [{ $ref: "#/components/parameters/EventId" }]
requestBody:
required: true
content: { application/json: { schema: { $ref: "#/components/schemas/BracketUpdateCommand" } } }
responses:
"200": { description: Updated graph, content: { application/json: { schema: { $ref: "#/components/schemas/BracketDraft" } } } }
"409": { $ref: "#/components/responses/Conflict" }
/api/events/{eventId}/bracket-draft/initialize:
post:
tags: [Tournaments]
operationId: initializeBracketDraft
parameters: [{ $ref: "#/components/parameters/EventId" }]
requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/VersionCommand" } } } }
responses:
"201": { description: Default bracket graph, content: { application/json: { schema: { $ref: "#/components/schemas/BracketDraft" } } } }
/api/events/{eventId}/bracket-draft/reset:
post:
tags: [Tournaments]
operationId: resetBracketDraft
parameters: [{ $ref: "#/components/parameters/EventId" }]
requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/VersionCommand" } } } }
responses:
"200": { description: Reset bracket graph, content: { application/json: { schema: { $ref: "#/components/schemas/BracketDraft" } } } }
/api/events/{eventId}/bracket-draft/confirm:
post:
tags: [Tournaments]
operationId: confirmBracketDraft
parameters: [{ $ref: "#/components/parameters/EventId" }]
requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/VersionCommand" } } } }
responses:
"200": { description: Confirmed bracket graph, content: { application/json: { schema: { $ref: "#/components/schemas/BracketDraft" } } } }
/api/events/{eventId}/rsvps:
get:
tags: [Events]
@@ -1012,7 +1053,7 @@ components:
format: date-time
state:
type: string
enum: [RegistrationOpen, RegistrationClosed, Balancing, RostersDraft, RostersConfirmed, Live, Completed, Cancelled]
enum: [RegistrationOpen, RegistrationClosed, Balancing, RostersDraft, RostersConfirmed, BracketDraft, Live, Completed, Cancelled]
version: { type: integer }
rulesetId: { type: string }
activeSeriesId: { type: string }
@@ -1229,9 +1270,45 @@ components:
type: object
version:
type: integer
BracketSlotSource:
type: object
required: [kind]
properties:
kind: { type: string, enum: [Team, Winner, Loser] }
teamId: { type: string }
matchId: { type: string }
BracketMatch:
type: object
required: [id, round, order, slotA, slotB]
properties:
id: { type: string }
round: { type: integer, minimum: 0 }
order: { type: integer, minimum: 0 }
slotA: { $ref: "#/components/schemas/BracketSlotSource" }
slotB: { $ref: "#/components/schemas/BracketSlotSource" }
seriesId: { type: string }
teamAId: { type: string }
teamBId: { type: string }
winnerTeamId: { type: string }
series: { $ref: "#/components/schemas/Series" }
BracketDraft:
type: object
required: [eventId, teamIds, matches, version, confirmed]
properties:
eventId: { type: string }
teamIds: { type: array, items: { type: string } }
matches: { type: array, items: { $ref: "#/components/schemas/BracketMatch" } }
version: { type: integer }
confirmed: { type: boolean }
BracketUpdateCommand:
type: object
required: [matches, expectedVersion]
properties:
matches: { type: array, items: { $ref: "#/components/schemas/BracketMatch" } }
expectedVersion: { type: integer }
Tournament:
type: object
required: [id, eventId, name, winnerTeamId, teamIds, rounds]
required: [id, eventId, name, winnerTeamId, teamIds, matches, rounds, version]
properties:
id:
type: string
@@ -1245,12 +1322,17 @@ components:
type: array
items:
type: string
matches:
type: array
items:
$ref: "#/components/schemas/BracketMatch"
rounds:
type: array
items:
type: array
items:
$ref: "#/components/schemas/Series"
version: { type: integer }
Problem:
type: object
required: [error]