Add Discord event announcement functionality to backend
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:
@@ -18,5 +18,10 @@ DISCORD_CLIENT_SECRET=
|
|||||||
DISCORD_REDIRECT_URL=http://localhost:5173/api/auth/discord/callback
|
DISCORD_REDIRECT_URL=http://localhost:5173/api/auth/discord/callback
|
||||||
ADMIN_DISCORD_IDS=
|
ADMIN_DISCORD_IDS=
|
||||||
|
|
||||||
|
# Discord event announcements (optional)
|
||||||
|
DISCORD_BOT_TOKEN=
|
||||||
|
DISCORD_ANNOUNCEMENT_CHANNEL_ID=
|
||||||
|
DISCORD_ANNOUNCEMENT_LOCALE=ru
|
||||||
|
|
||||||
# Browser-facing build configuration
|
# Browser-facing build configuration
|
||||||
VITE_API_BASE_URL=/api
|
VITE_API_BASE_URL=/api
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
discordadapter "mixmaker/backend/internal/adapter/discord"
|
||||||
"mixmaker/backend/internal/adapter/httpapi"
|
"mixmaker/backend/internal/adapter/httpapi"
|
||||||
"mixmaker/backend/internal/adapter/postgres"
|
"mixmaker/backend/internal/adapter/postgres"
|
||||||
"mixmaker/backend/internal/application"
|
"mixmaker/backend/internal/application"
|
||||||
@@ -37,7 +38,22 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
hub := realtime.New()
|
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{
|
cfg := httpapi.Config{
|
||||||
DiscordClientID: required("DISCORD_CLIENT_ID"),
|
DiscordClientID: required("DISCORD_CLIENT_ID"),
|
||||||
DiscordClientSecret: required("DISCORD_CLIENT_SECRET"),
|
DiscordClientSecret: required("DISCORD_CLIENT_SECRET"),
|
||||||
|
|||||||
203
backend/internal/adapter/discord/announcer.go
Normal file
203
backend/internal/adapter/discord/announcer.go
Normal 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]) + "…"
|
||||||
|
}
|
||||||
97
backend/internal/adapter/discord/announcer_test.go
Normal file
97
backend/internal/adapter/discord/announcer_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Post("/api/events/{eventID}/roster/emergency-substitute", s.emergencySubstitute)
|
||||||
api.Put("/api/events/{eventID}/roster/captain", s.setRosterCaptain)
|
api.Put("/api/events/{eventID}/roster/captain", s.setRosterCaptain)
|
||||||
api.Post("/api/events/{eventID}/roster/confirm", s.confirmRosters)
|
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}/workflow/back", s.revertWorkflowStage)
|
||||||
api.Post("/api/events/{eventID}/start", s.startScrim)
|
api.Post("/api/events/{eventID}/start", s.startScrim)
|
||||||
api.Put("/api/teams/{teamID}/captain", s.assignCaptain)
|
api.Put("/api/teams/{teamID}/captain", s.assignCaptain)
|
||||||
|
|||||||
@@ -168,6 +168,56 @@ func (s *Server) confirmRosters(w http.ResponseWriter, r *http.Request) {
|
|||||||
respond(w, out, err, http.StatusOK)
|
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) {
|
func (s *Server) revertWorkflowStage(w http.ResponseWriter, r *http.Request) {
|
||||||
var in struct {
|
var in struct {
|
||||||
ExpectedVersion int `json:"expectedVersion"`
|
ExpectedVersion int `json:"expectedVersion"`
|
||||||
|
|||||||
@@ -371,6 +371,40 @@ func (s *Store) ResetRoster(ctx context.Context, eventID string) error {
|
|||||||
return tx.Commit(ctx)
|
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 {
|
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)
|
tx, err := s.pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -386,8 +420,8 @@ func (s *Store) StartScrim(ctx context.Context, event domain.Event, expectedVers
|
|||||||
}
|
}
|
||||||
if tournament != nil {
|
if tournament != nil {
|
||||||
body, _ := json.Marshal(tournament)
|
body, _ := json.Marshal(tournament)
|
||||||
if _, err = tx.Exec(ctx, `INSERT INTO tournaments(id,event_id,body) VALUES($1,$2,$3)`,
|
if _, err = tx.Exec(ctx, `INSERT INTO tournaments(id,event_id,body,version) VALUES($1,$2,$3,$4)`,
|
||||||
tournament.ID, tournament.EventID, body); err != nil {
|
tournament.ID, tournament.EventID, body, tournament.Version); err != nil {
|
||||||
return mapError(err)
|
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) {
|
func (s *Store) SaveTournament(ctx context.Context, t domain.Tournament) (domain.Tournament, error) {
|
||||||
body, _ := json.Marshal(t)
|
body, _ := json.Marshal(t)
|
||||||
_, err := s.pool.Exec(ctx, `INSERT INTO tournaments(id,event_id,body) VALUES($1,$2,$3)
|
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`, t.ID, t.EventID, body)
|
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
|
return t, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,6 +690,24 @@ func (s *Store) hydrateTournament(ctx context.Context, tournament *domain.Tourna
|
|||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return err
|
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 round := range tournament.Rounds {
|
||||||
for match := range tournament.Rounds[round] {
|
for match := range tournament.Rounds[round] {
|
||||||
if series, ok := live[tournament.Rounds[round][match].ID]; ok {
|
if series, ok := live[tournament.Rounds[round][match].ID]; ok {
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ func TestMigrationsAndReadiness(t *testing.T) {
|
|||||||
if !rosterTable {
|
if !rosterTable {
|
||||||
t.Fatal("event_rosters table is missing")
|
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) {
|
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)
|
_, _ = 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}
|
admin := domain.Account{ID: accountID, Role: domain.RoleAdmin}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
event, err := service.CreateEvent(ctx, admin, domain.Event{
|
event, err := service.CreateEvent(ctx, admin, domain.Event{
|
||||||
@@ -130,6 +137,18 @@ func TestFullScrimPipeline(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
started, err := service.StartScrim(ctx, admin, eventID, event.Version)
|
||||||
if err != nil || started.Series == nil {
|
if err != nil || started.Series == nil {
|
||||||
t.Fatalf("start failed: %#v, %v", started, err)
|
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 events WHERE id=$1`, eventID)
|
||||||
_, _ = store.pool.Exec(ctx, `DELETE FROM accounts WHERE id=$1`, accountID)
|
_, _ = 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{
|
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),
|
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",
|
RegistrationDeadline: time.Now(), RulesetID: "standard-control-hybrid-control",
|
||||||
@@ -213,15 +232,15 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for match := range tournament.Rounds[0] {
|
for match, slot := range tournament.ReadyMatches() {
|
||||||
slot := tournament.Rounds[0][match]
|
|
||||||
series, createErr := domain.NewSeries(slot.ID, eventID, tournamentID, [2]string{slot.TeamAID, slot.TeamBID}, rules)
|
series, createErr := domain.NewSeries(slot.ID, eventID, tournamentID, [2]string{slot.TeamAID, slot.TeamBID}, rules)
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
t.Fatal(createErr)
|
t.Fatal(createErr)
|
||||||
@@ -234,6 +253,16 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
|
|||||||
if _, createErr = store.SaveSeries(ctx, *series); createErr != nil {
|
if _, createErr = store.SaveSeries(ctx, *series); createErr != nil {
|
||||||
t.Fatal(createErr)
|
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)
|
hydrated, err := store.GetTournamentByEvent(ctx, eventID)
|
||||||
if err != nil {
|
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 {
|
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)
|
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] {
|
for index := range hydrated.Matches {
|
||||||
hydrated.Rounds[0][match].WinnerTeamID = hydrated.Rounds[0][match].TeamAID
|
if hydrated.Matches[index].Round != 0 || hydrated.Matches[index].Series == nil {
|
||||||
hydrated.Rounds[0][match].Phase = domain.SeriesComplete
|
continue
|
||||||
if err = hydrated.Advance(0, match); err != nil {
|
}
|
||||||
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err = store.SaveTournament(ctx, hydrated); err != nil {
|
finalSlot := hydrated.ReadyMatches()[0]
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
finalSlot := hydrated.Rounds[1][0]
|
|
||||||
final, err := domain.NewSeries(finalSlot.ID, eventID, tournamentID, [2]string{finalSlot.TeamAID, finalSlot.TeamBID}, rules)
|
final, err := domain.NewSeries(finalSlot.ID, eventID, tournamentID, [2]string{finalSlot.TeamAID, finalSlot.TeamBID}, rules)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -261,8 +296,8 @@ func TestParallelTournamentHydratesLiveSeriesAndFinal(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
hydrated, err = store.GetTournamentByEvent(ctx, eventID)
|
hydrated, err = store.GetTournamentByEvent(ctx, eventID)
|
||||||
if err != nil || hydrated.Rounds[1][0].Phase != domain.CoinTossPending {
|
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: phase=%s err=%v", hydrated.Rounds[1][0].Phase, err)
|
t.Fatalf("final was not persisted and hydrated: err=%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ type Store interface {
|
|||||||
SaveRoster(context.Context, domain.RosterDraft, int) (domain.RosterDraft, error)
|
SaveRoster(context.Context, domain.RosterDraft, int) (domain.RosterDraft, error)
|
||||||
GetRoster(context.Context, string) (domain.RosterDraft, error)
|
GetRoster(context.Context, string) (domain.RosterDraft, error)
|
||||||
ResetRoster(context.Context, string) 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
|
StartScrim(context.Context, domain.Event, int, []domain.Series, *domain.Tournament) error
|
||||||
DeleteEvent(context.Context, string) error
|
DeleteEvent(context.Context, string) error
|
||||||
UpsertRSVP(context.Context, domain.RSVP) (domain.RSVP, 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 Publisher interface{ Publish(topic string, value any) }
|
||||||
|
|
||||||
type Service struct {
|
type EventAnnouncer interface {
|
||||||
Store Store
|
AnnounceEventCreated(context.Context, domain.Event) error
|
||||||
Bus Publisher
|
|
||||||
Now func() time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(store Store, bus Publisher) *Service {
|
type Service struct {
|
||||||
return &Service{Store: store, Bus: bus, Now: func() time.Time { return time.Now().UTC() }}
|
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 {
|
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)
|
out, err := s.Store.CreateEvent(ctx, event)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
s.Bus.Publish("events", out)
|
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
|
return out, err
|
||||||
}
|
}
|
||||||
|
|||||||
112
backend/internal/application/service_test.go
Normal file
112
backend/internal/application/service_test.go
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -393,6 +393,132 @@ func (s *Service) ConfirmRosters(ctx context.Context, actor domain.Account, even
|
|||||||
return event, err
|
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) {
|
func (s *Service) RevertWorkflowStage(ctx context.Context, actor domain.Account, eventID string, expectedVersion int) (domain.Event, error) {
|
||||||
if !actor.IsStaff() {
|
if !actor.IsStaff() {
|
||||||
return domain.Event{}, domain.ErrForbidden
|
return domain.Event{}, domain.ErrForbidden
|
||||||
@@ -412,6 +538,8 @@ func (s *Service) RevertWorkflowStage(ctx context.Context, actor domain.Account,
|
|||||||
previous = domain.Balancing
|
previous = domain.Balancing
|
||||||
case domain.RostersConfirmed:
|
case domain.RostersConfirmed:
|
||||||
previous = domain.RostersDraft
|
previous = domain.RostersDraft
|
||||||
|
case domain.BracketDraftState:
|
||||||
|
previous = domain.RostersConfirmed
|
||||||
default:
|
default:
|
||||||
return event, fmt.Errorf("%w: this workflow stage cannot be reverted", domain.ErrConflict)
|
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
|
return event, rosterErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if event.State == domain.RostersConfirmed && previous == domain.RostersConfirmed {
|
||||||
|
_ = s.Store.DeleteBracketDraft(ctx, eventID)
|
||||||
|
}
|
||||||
event.UpdatedAt = s.Now()
|
event.UpdatedAt = s.Now()
|
||||||
event, err = s.Store.SaveEventWorkflow(ctx, event, oldVersion)
|
event, err = s.Store.SaveEventWorkflow(ctx, event, oldVersion)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -459,52 +590,50 @@ func (s *Service) StartScrim(ctx context.Context, actor domain.Account, eventID
|
|||||||
if !roster.Confirmed {
|
if !roster.Confirmed {
|
||||||
return ScrimStart{}, fmt.Errorf("%w: rosters are not confirmed", domain.ErrConflict)
|
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)
|
rules, err := s.Store.GetRuleset(ctx, event.RulesetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ScrimStart{}, err
|
return ScrimStart{}, err
|
||||||
}
|
}
|
||||||
oldVersion := event.Version
|
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
|
return ScrimStart{}, err
|
||||||
}
|
}
|
||||||
result := ScrimStart{}
|
result := ScrimStart{}
|
||||||
seriesToSave := make([]domain.Series, 0)
|
seriesToSave := make([]domain.Series, 0)
|
||||||
teamIDs := make([]string, len(roster.Teams))
|
tournament, err := domain.NewGraphTournament(NewID(), eventID, event.Name, draft)
|
||||||
for i := range roster.Teams {
|
if err != nil {
|
||||||
teamIDs[i] = roster.Teams[i].ID
|
return ScrimStart{}, err
|
||||||
}
|
}
|
||||||
if len(teamIDs) == 2 {
|
for _, ready := range tournament.ReadyMatches() {
|
||||||
series, createErr := domain.NewSeries(NewID(), eventID, "", [2]string{teamIDs[0], teamIDs[1]}, rules)
|
series, createErr := domain.NewSeries(ready.ID, eventID, tournament.ID, [2]string{ready.TeamAID, ready.TeamBID}, rules)
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
return ScrimStart{}, createErr
|
return ScrimStart{}, createErr
|
||||||
}
|
}
|
||||||
seriesToSave = append(seriesToSave, *series)
|
for index := range tournament.Matches {
|
||||||
event.ActiveSeriesID = series.ID
|
if tournament.Matches[index].ID == ready.ID {
|
||||||
result.Series = series
|
tournament.Matches[index].SeriesID = series.ID
|
||||||
} else if len(teamIDs) >= 4 {
|
tournament.Matches[index].Series = series
|
||||||
tournament, createErr := domain.NewTournament(NewID(), eventID, event.Name, teamIDs)
|
|
||||||
if createErr != nil {
|
|
||||||
return ScrimStart{}, createErr
|
|
||||||
}
|
|
||||||
for round := range tournament.Rounds {
|
|
||||||
for match := range tournament.Rounds[round] {
|
|
||||||
base := tournament.Rounds[round][match]
|
|
||||||
if base.TeamAID == "" || base.TeamBID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
series, seriesErr := domain.NewSeries(base.ID, eventID, tournament.ID, [2]string{base.TeamAID, base.TeamBID}, rules)
|
|
||||||
if seriesErr != nil {
|
|
||||||
return ScrimStart{}, seriesErr
|
|
||||||
}
|
|
||||||
tournament.Rounds[round][match] = *series
|
|
||||||
seriesToSave = append(seriesToSave, *series)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
event.TournamentID = tournament.ID
|
seriesToSave = append(seriesToSave, *series)
|
||||||
result.Tournament = tournament
|
if result.Series == nil {
|
||||||
} else {
|
result.Series = series
|
||||||
return ScrimStart{}, fmt.Errorf("%w: scrim needs two or at least four teams", domain.ErrInvalid)
|
event.ActiveSeriesID = series.ID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
tournament.BuildRounds()
|
||||||
|
event.TournamentID = tournament.ID
|
||||||
|
result.Tournament = tournament
|
||||||
event.UpdatedAt = s.Now()
|
event.UpdatedAt = s.Now()
|
||||||
err = s.Store.StartScrim(ctx, event, oldVersion, seriesToSave, result.Tournament)
|
err = s.Store.StartScrim(ctx, event, oldVersion, seriesToSave, result.Tournament)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -562,6 +691,9 @@ func (s *Service) RecordSeriesResult(ctx context.Context, actor domain.Account,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
if len(tournament.Matches) > 0 {
|
||||||
|
return out, s.advanceGraphTournament(ctx, out, tournament)
|
||||||
|
}
|
||||||
for round := range tournament.Rounds {
|
for round := range tournament.Rounds {
|
||||||
for match := range tournament.Rounds[round] {
|
for match := range tournament.Rounds[round] {
|
||||||
if tournament.Rounds[round][match].ID != out.ID {
|
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
|
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 = ©
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
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)
|
series, err := s.Store.GetSeries(ctx, seriesID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
262
backend/internal/domain/bracket.go
Normal file
262
backend/internal/domain/bracket.go
Normal 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 = ©
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
backend/internal/domain/bracket_test.go
Normal file
51
backend/internal/domain/bracket_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ const (
|
|||||||
Balancing EventState = "Balancing"
|
Balancing EventState = "Balancing"
|
||||||
RostersDraft EventState = "RostersDraft"
|
RostersDraft EventState = "RostersDraft"
|
||||||
RostersConfirmed EventState = "RostersConfirmed"
|
RostersConfirmed EventState = "RostersConfirmed"
|
||||||
|
BracketDraftState EventState = "BracketDraft"
|
||||||
Live EventState = "Live"
|
Live EventState = "Live"
|
||||||
Completed EventState = "Completed"
|
Completed EventState = "Completed"
|
||||||
Cancelled EventState = "Cancelled"
|
Cancelled EventState = "Cancelled"
|
||||||
|
|||||||
@@ -468,12 +468,14 @@ func (s *Series) recalculateWinner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Tournament struct {
|
type Tournament struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
EventID string `json:"eventId"`
|
EventID string `json:"eventId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
WinnerTeamID string `json:"winnerTeamId"`
|
WinnerTeamID string `json:"winnerTeamId"`
|
||||||
TeamIDs []string `json:"teamIds"`
|
TeamIDs []string `json:"teamIds"`
|
||||||
Rounds [][]Series `json:"rounds"`
|
Matches []BracketMatch `json:"matches,omitempty"`
|
||||||
|
Rounds [][]Series `json:"rounds"`
|
||||||
|
Version int `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTournament(id, eventID, name string, teams []string) (*Tournament, error) {
|
func NewTournament(id, eventID, name string, teams []string) (*Tournament, error) {
|
||||||
|
|||||||
26
backend/migrations/010_custom_brackets.sql
Normal file
26
backend/migrations/010_custom_brackets.sql
Normal 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'
|
||||||
|
));
|
||||||
@@ -33,6 +33,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
APP_ENV: production
|
APP_ENV: production
|
||||||
HTTP_ADDR: :8080
|
HTTP_ADDR: :8080
|
||||||
|
PUBLIC_URL: ${PUBLIC_URL:?PUBLIC_URL is required}
|
||||||
FRONTEND_URL: ${PUBLIC_URL:?PUBLIC_URL is required}
|
FRONTEND_URL: ${PUBLIC_URL:?PUBLIC_URL is required}
|
||||||
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
|
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
|
||||||
SESSION_TTL: ${SESSION_TTL:-168h}
|
SESSION_TTL: ${SESSION_TTL:-168h}
|
||||||
@@ -40,6 +41,9 @@ services:
|
|||||||
DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET:?DISCORD_CLIENT_SECRET is required}
|
DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET:?DISCORD_CLIENT_SECRET is required}
|
||||||
DISCORD_REDIRECT_URL: ${DISCORD_REDIRECT_URL:?DISCORD_REDIRECT_URL is required}
|
DISCORD_REDIRECT_URL: ${DISCORD_REDIRECT_URL:?DISCORD_REDIRECT_URL is required}
|
||||||
ADMIN_DISCORD_IDS: ${ADMIN_DISCORD_IDS:-}
|
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}
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
depends_on:
|
depends_on:
|
||||||
migrations:
|
migrations:
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ Set these as production variables in Coolify:
|
|||||||
- `DISCORD_CLIENT_SECRET`
|
- `DISCORD_CLIENT_SECRET`
|
||||||
- `DISCORD_REDIRECT_URL=https://mix.example.com/api/auth/discord/callback`
|
- `DISCORD_REDIRECT_URL=https://mix.example.com/api/auth/discord/callback`
|
||||||
- `ADMIN_DISCORD_IDS` — comma-separated Discord user IDs
|
- `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.
|
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.
|
3. Use only the `identify` OAuth scope.
|
||||||
4. Store the client secret only in Coolify.
|
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
|
## Storage and backups
|
||||||
|
|
||||||
PostgreSQL uses the `postgres_data` named volume. Confirm that Coolify recognizes it as persistent storage before accepting users.
|
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.
|
3. Open `/health` through the public domain.
|
||||||
4. Complete Discord login and verify the admin role.
|
4. Complete Discord login and verify the admin role.
|
||||||
5. Create a test event and RSVP.
|
5. Create a test event and RSVP.
|
||||||
6. Restart/redeploy and confirm the event remains.
|
6. Confirm the bot posts one announcement with an `@everyone` mention and a working registration button.
|
||||||
7. Verify SSE remains connected through the proxy.
|
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.
|
Rollback application images only when the database migration is backward-compatible. Otherwise restore the matching database backup.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { LanguageSelector } from './i18n'
|
|||||||
import { useLanguage } from './i18n-context'
|
import { useLanguage } from './i18n-context'
|
||||||
import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks'
|
import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks'
|
||||||
import { activeHeroBanMap } from './series-utils'
|
import { activeHeroBanMap } from './series-utils'
|
||||||
|
import { BracketEditor } from './components/bracket-editor/BracketEditor'
|
||||||
|
|
||||||
type RouterContext = { session: Session | null }
|
type RouterContext = { session: Session | null }
|
||||||
const isStaff = (role?: Session['account']['role']) => role === 'admin' || role === 'moderator'
|
const isStaff = (role?: Session['account']['role']) => role === 'admin' || role === 'moderator'
|
||||||
@@ -86,6 +87,8 @@ function AppShell() {
|
|||||||
void client.invalidateQueries({ queryKey: ['series', id] })
|
void client.invalidateQueries({ queryKey: ['series', id] })
|
||||||
} else if (scope === 'tournament') {
|
} else if (scope === 'tournament') {
|
||||||
void client.invalidateQueries({ queryKey: ['tournament'] })
|
void client.invalidateQueries({ queryKey: ['tournament'] })
|
||||||
|
} else if (scope === 'bracket' && id) {
|
||||||
|
void client.invalidateQueries({ queryKey: ['bracket-draft', id] })
|
||||||
} else if (scope === 'team') {
|
} else if (scope === 'team') {
|
||||||
void client.invalidateQueries({ queryKey: ['teams'] })
|
void client.invalidateQueries({ queryKey: ['teams'] })
|
||||||
void client.invalidateQueries({ queryKey: ['series'] })
|
void client.invalidateQueries({ queryKey: ['series'] })
|
||||||
@@ -475,9 +478,20 @@ function WorkflowControl({ eventId }: { eventId: string }) {
|
|||||||
const roster = useQuery({
|
const roster = useQuery({
|
||||||
queryKey: ['roster', eventId],
|
queryKey: ['roster', eventId],
|
||||||
queryFn: () => api.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,
|
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 [candidates, setCandidates] = useState<Awaited<ReturnType<typeof api.generateWorkflowBalance>>['candidates']>([])
|
||||||
const [selectedCandidate, setSelectedCandidate] = useState(0)
|
const [selectedCandidate, setSelectedCandidate] = useState(0)
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
@@ -510,6 +524,29 @@ function WorkflowControl({ eventId }: { eventId: string }) {
|
|||||||
onSuccess: refresh,
|
onSuccess: refresh,
|
||||||
onError: 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({
|
const start = useMutation({
|
||||||
mutationFn: () => api.startScrim(eventId, event.data!.version),
|
mutationFn: () => api.startScrim(eventId, event.data!.version),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
@@ -527,6 +564,7 @@ function WorkflowControl({ eventId }: { eventId: string }) {
|
|||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
if (updated.workflowState === 'RegistrationClosed') setCandidates([])
|
if (updated.workflowState === 'RegistrationClosed') setCandidates([])
|
||||||
if (updated.workflowState === 'Balancing') client.removeQueries({ queryKey: ['roster', eventId] })
|
if (updated.workflowState === 'Balancing') client.removeQueries({ queryKey: ['roster', eventId] })
|
||||||
|
if (updated.workflowState === 'RostersConfirmed') client.removeQueries({ queryKey: ['bracket-draft', eventId] })
|
||||||
refresh()
|
refresh()
|
||||||
},
|
},
|
||||||
onError: 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.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() }} />
|
if (event.isError || players.isError || !event.data || !players.data) return <ErrorState retry={() => { void event.refetch(); void players.refetch() }} />
|
||||||
const state = event.data.workflowState
|
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">
|
return <div className="workflow-control">
|
||||||
<section className="card workflow-progress">
|
<section className="card workflow-progress">
|
||||||
{(['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'] as const).map((step, index) => {
|
{(['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'] as const).map((step, index) => {
|
||||||
const activeIndex = ['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].indexOf(state)
|
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>
|
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>
|
</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 === '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 && <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>}
|
{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 === '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 === '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>}
|
{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>}
|
{error && <p className="error-note">{error.message}</p>}
|
||||||
|
|||||||
@@ -80,6 +80,21 @@ describe('backend DTO adapters', () => {
|
|||||||
expect(adaptSeries(series).score).toEqual({ alpha: 0, beta: 1 })
|
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(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')
|
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({
|
const heroDraft = adaptSeries({
|
||||||
...series,
|
...series,
|
||||||
phase: 'HeroBan',
|
phase: 'HeroBan',
|
||||||
@@ -149,4 +164,23 @@ describe('actual backend routes', () => {
|
|||||||
body: JSON.stringify({ name: 'Ember Wolves' }),
|
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 }),
|
||||||
|
}))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export interface MixEvent {
|
|||||||
activeSeriesId?: string
|
activeSeriesId?: string
|
||||||
tournamentId?: 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 TeamPlayer { player: Player; assignedRole: PlayerRole }
|
||||||
export interface Team {
|
export interface Team {
|
||||||
id: string
|
id: string
|
||||||
@@ -122,6 +122,27 @@ export interface EventInput {
|
|||||||
endsAt: string
|
endsAt: string
|
||||||
registrationDeadline: 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.
|
// Raw backend DTOs mirror Go's current camelCase JSON tags exactly.
|
||||||
export interface RawAccount {
|
export interface RawAccount {
|
||||||
@@ -174,7 +195,7 @@ export interface RawSeries {
|
|||||||
export interface RawDraftAction { teamId: string; value: string; actorAccountId: string; at: string }
|
export interface RawDraftAction { teamId: string; value: string; actorAccountId: string; at: string }
|
||||||
export interface RawTournament {
|
export interface RawTournament {
|
||||||
id: string; eventId: string; name: string; winnerTeamId: string
|
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 }
|
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 {
|
export function adaptTournament(raw: RawTournament, teams: Team[] = []): Bracket {
|
||||||
const names = new Map(teams.map((team) => [team.id, team.name]))
|
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 {
|
return {
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
title: raw.name,
|
title: raw.name,
|
||||||
@@ -599,6 +651,15 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ expectedVersion, expectedRosterVersion }),
|
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) => {
|
startScrim: async (eventId: string, expectedVersion: number) => {
|
||||||
const result = await request<{ event: RawEvent; series?: RawSeries; tournament?: RawTournament }>(`/events/${eventId}/start`, {
|
const result = await request<{ event: RawEvent; series?: RawSeries; tournament?: RawTournament }>(`/events/${eventId}/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
BalanceCandidate,
|
BalanceCandidate,
|
||||||
Bracket,
|
Bracket,
|
||||||
|
BracketDraft,
|
||||||
MixEvent,
|
MixEvent,
|
||||||
Player,
|
Player,
|
||||||
Registration,
|
Registration,
|
||||||
@@ -294,3 +295,15 @@ export const demoBracket: Bracket = {
|
|||||||
{ id: 'm3', round: 1, slot: 0, label: 'Grand Final', teamBeta: 'Neon Foxes', status: 'pending' },
|
{ 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' } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
93
frontend/src/components/bracket-editor/BracketEditor.tsx
Normal file
93
frontend/src/components/bracket-editor/BracketEditor.tsx
Normal 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>
|
||||||
|
}
|
||||||
@@ -249,6 +249,24 @@ const translations: Record<string, string> = {
|
|||||||
'Confirm rosters': 'Подтвердить составы',
|
'Confirm rosters': 'Подтвердить составы',
|
||||||
'Every team must have a valid 1/2/2 lineup and a captain.': 'В каждой команде должен быть состав 1/2/2 и капитан.',
|
'Every team must have a valid 1/2/2 lineup and a captain.': 'В каждой команде должен быть состав 1/2/2 и капитан.',
|
||||||
'Start scrim': 'Начать скрим',
|
'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 или турнирная сетка будут созданы атомарно.',
|
'This atomically creates the Bo3 series or tournament bracket.': 'Серия Bo3 или турнирная сетка будут созданы атомарно.',
|
||||||
'Scrim is live': 'Скрим начался',
|
'Scrim is live': 'Скрим начался',
|
||||||
'Coin toss, bans, and map results are now available.': 'Теперь доступны жеребьёвка, баны и результаты карт.',
|
'Coin toss, bans, and map results are now available.': 'Теперь доступны жеребьёвка, баны и результаты карт.',
|
||||||
|
|||||||
@@ -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; }
|
.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; }
|
.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-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 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 span { width: 23px; height: 23px; display: grid; place-items: center; border: 1px solid #3a414b; border-radius: 50%; }
|
||||||
.workflow-progress div.done { color: var(--green); }.workflow-progress div.done span { border-color: #2d7654; background: #10271c; }
|
.workflow-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; }
|
.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; }
|
.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); }
|
.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; }
|
.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; }
|
.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; }
|
.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; }
|
.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%; }
|
.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; }
|
.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; }
|
.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; }
|
.bracket-scroll { padding: 25px; gap: 70px; }.bracket-match::after { width: 70px; right: -71px; }
|
||||||
|
|||||||
@@ -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.
|
Admin UI содержит пошаговый workflow и roster editor с same-role swap, резервом и запуском. Live, spectator и bracket используют реальные API-команды и realtime refresh; участник турнира автоматически открывает собственный матч, остальные попадают в spectator mode, а актуальная сетка и соседние параллельные матчи доступны прямо над серией. Demo fixtures остались только локальным showcase.
|
||||||
Добавлена роль Moderator: она имеет все операционные права Admin, но управление списком модераторов доступно только Admin. До состояния Live workflow можно откатить на один этап назад.
|
Добавлена роль 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;
|
- PostgreSQL в том же Compose-стеке с persistent volume;
|
||||||
- мобильный и десктопный интерфейс.
|
- мобильный и десктопный интерфейс.
|
||||||
- карточки отменённых событий отображаются красными, завершённых — зелёными, активных — розовыми с меткой LIVE.
|
- карточки отменённых событий отображаются красными, завершённых — зелёными, активных — розовыми с меткой LIVE.
|
||||||
|
- новый микс может автоматически анонсироваться в настроенном Discord-канале со ссылкой на регистрацию.
|
||||||
|
|
||||||
## Исходный регламент
|
## Исходный регламент
|
||||||
|
|
||||||
@@ -61,7 +63,7 @@ Admin UI содержит пошаговый workflow и roster editor с same-r
|
|||||||
- балансировщик автоматически назначает роли и собирает команды 1/2/2;
|
- балансировщик автоматически назначает роли и собирает команды 1/2/2;
|
||||||
- предпочтения ролей, союзников и avoid-списки учитываются как мягкие штрафы после требований баланса;
|
- предпочтения ролей, союзников и avoid-списки учитываются как мягкие штрафы после требований баланса;
|
||||||
- участники сверх полного состава остаются в резерве;
|
- участники сверх полного состава остаются в резерве;
|
||||||
- первая турнирная сетка — single elimination;
|
- сетка редактируется как последовательный граф Team / Winner / Loser; последний матч определяет общего победителя;
|
||||||
- игрок отмечается на микс через календарь;
|
- игрок отмечается на микс через календарь;
|
||||||
- Admin может изменить RSVP за игрока, а действие сохраняет автора;
|
- 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.
|
Публичная вкладка FAQ описывает регистрацию, балансировку, полномочия капитанов, выбор карт, баны героев и подсчёт Bo3.
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ Application-слой управляет единым versioned workflow собы
|
|||||||
|
|
||||||
Ручное редактирование работает с серверным roster draft. Backend разрешает обмен только между одинаковыми ролевыми слотами и замену слота игроком из резерва, после чего пересчитывает средние рейтинги и метрики. Подтверждение требует полного состава 1/2/2, уникальных игроков и капитана внутри каждой команды.
|
Ручное редактирование работает с серверным 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 и держит соединение открытым.
|
Один глобальный 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 имеет аварийное право выполнить действие с обязательной записью в аудит.
|
Капитан — назначение внутри конкретной команды, а не глобальная роль аккаунта. Admin может назначить или заменить капитана только участником этой команды. Только текущий капитан выполняет командные действия драфта; Admin имеет аварийное право выполнить действие с обязательной записью в аудит.
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
|
|
||||||
Отметка игрока в календаре со статусом Going, Maybe или NotGoing. Игрок меняет собственный статус, а Admin может изменить его за игрока. Регистрация хранит автора и время последнего изменения, чтобы интерфейс явно показывал административное действие. В момент закрытия регистрации Admin подтверждает список участников; только Going-игроки по умолчанию попадают в балансировку.
|
Отметка игрока в календаре со статусом Going, Maybe или NotGoing. Игрок меняет собственный статус, а Admin может изменить его за игрока. Регистрация хранит автора и время последнего изменения, чтобы интерфейс явно показывал административное действие. В момент закрытия регистрации Admin подтверждает список участников; только Going-игроки по умолчанию попадают в балансировку.
|
||||||
|
|
||||||
|
### Discord-анонс события
|
||||||
|
|
||||||
|
Созданный микс проецируется во внешний Discord-канал как информационный анонс со ссылкой на страницу Event. Анонс не является источником состояния регистрации: RSVP, доступность события и все переходы workflow по-прежнему определяет backend Mixmaker. Недоступность Discord не отменяет валидное создание Event.
|
||||||
|
|
||||||
### Жизненный цикл события
|
### Жизненный цикл события
|
||||||
|
|
||||||
Событие проходит серверно контролируемые состояния:
|
Событие проходит серверно контролируемые состояния:
|
||||||
@@ -29,9 +33,10 @@
|
|||||||
3. `Balancing` — создаются варианты команд и резерв;
|
3. `Balancing` — создаются варианты команд и резерв;
|
||||||
4. `RostersDraft` — Admin выбирает вариант, меняет игроков одной роли местами и заменяет их резервными;
|
4. `RostersDraft` — Admin выбирает вариант, меняет игроков одной роли местами и заменяет их резервными;
|
||||||
5. `RostersConfirmed` — составы 1/2/2 и капитаны проверены и заблокированы;
|
5. `RostersConfirmed` — составы 1/2/2 и капитаны проверены и заблокированы;
|
||||||
6. `Live` — создана серия, разрешены только действия текущего шага жеребьёвки, банов или результата;
|
6. `BracketDraft` — staff задаёт порядок Bo3 и источники Team / Winner / Loser для слотов;
|
||||||
7. `Completed` — серия или турнир завершены;
|
7. `Live` — созданы готовые серии, разрешены только действия текущего шага жеребьёвки, банов или результата;
|
||||||
8. `Cancelled` — Admin отменил событие; оно остаётся в календаре, но игровые действия заблокированы.
|
8. `Completed` — выполнены все запланированные матчи;
|
||||||
|
9. `Cancelled` — Admin отменил событие; оно остаётся в календаре, но игровые действия заблокированы.
|
||||||
|
|
||||||
Переходы выполняются отдельными командами API с проверкой ожидаемой версии. До запуска скрима staff-пользователь может вернуться на один этап назад: черновик удаляется при возврате из `RostersDraft`, а состав снова разблокируется при возврате из `RostersConfirmed`. Возврат из `Live` и `Completed` запрещён. Нельзя начать скрим без минимум двух полных команд, валидного состава 1/2/2 и капитана в каждой команде. После `RostersConfirmed` обычная перестановка запрещена. Аварийная замена во время `Live` доступна staff-пользователю, должна сохранять ролевой состав и записывается в аудит. Полное удаление события является отдельной staff-командой и каскадно удаляет его RSVP, составы, roster draft, серии, турнир и event-scoped аудит.
|
Переходы выполняются отдельными командами API с проверкой ожидаемой версии. До запуска скрима staff-пользователь может вернуться на один этап назад: черновик удаляется при возврате из `RostersDraft`, а состав снова разблокируется при возврате из `RostersConfirmed`. Возврат из `Live` и `Completed` запрещён. Нельзя начать скрим без минимум двух полных команд, валидного состава 1/2/2 и капитана в каждой команде. После `RostersConfirmed` обычная перестановка запрещена. Аварийная замена во время `Live` доступна staff-пользователю, должна сохранять ролевой состав и записывается в аудит. Полное удаление события является отдельной staff-командой и каскадно удаляет его RSVP, составы, roster draft, серии, турнир и event-scoped аудит.
|
||||||
|
|
||||||
@@ -73,7 +78,7 @@
|
|||||||
|
|
||||||
### Tournament
|
### Tournament
|
||||||
|
|
||||||
Формат и состояние турнирной сетки, участники, раунды, пары и продвижение победителей.
|
Versioned граф матчей, в котором каждый слот ссылается на конкретную Team либо Winner/Loser матча из предыдущей колонки. Готовые зависимости материализуются в отдельные Bo3-серии автоматически; несколько готовых матчей могут идти параллельно. Последняя колонка содержит один матч, победитель которого становится общим победителем. Для трёх команд стартовый шаблон проводит `A–B`, затем `Loser(M1)–C`, затем финал победителей первых двух матчей.
|
||||||
|
|
||||||
## Правила текущего регламента
|
## Правила текущего регламента
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
- [x] дать Admin обменивать игроков одинаковой роли и заменять их резервными;
|
- [x] дать Admin обменивать игроков одинаковой роли и заменять их резервными;
|
||||||
- [x] пересчитывать показатели после каждой ручной правки;
|
- [x] пересчитывать показатели после каждой ручной правки;
|
||||||
- [x] подтвердить составы 1/2/2 и капитанов;
|
- [x] подтвердить составы 1/2/2 и капитанов;
|
||||||
- [x] запускать одиночную Bo3-серию для двух команд или single-elimination турнир для нескольких;
|
- [x] редактировать произвольный граф Bo3 через Team / Winner / Loser и запускать все готовые пары;
|
||||||
- [x] связать жеребьёвку, map draft, hero draft и результаты карт единым versioned FSM;
|
- [x] связать жеребьёвку, map draft, hero draft и результаты карт единым versioned FSM;
|
||||||
- [x] блокировать обычное изменение составов после старта;
|
- [x] блокировать обычное изменение составов после старта;
|
||||||
- [x] разрешить аудируемую аварийную замену Admin;
|
- [x] разрешить аудируемую аварийную замену Admin;
|
||||||
@@ -69,8 +69,8 @@
|
|||||||
|
|
||||||
## Этап 4 — турнир
|
## Этап 4 — турнир
|
||||||
|
|
||||||
- single-elimination сетка;
|
- versioned drag-and-drop редактор произвольной сетки;
|
||||||
- автоматическое создание следующих пар;
|
- автоматическое создание следующих пар по Winner/Loser-зависимостям;
|
||||||
- экран трансляции/зрителя;
|
- экран трансляции/зрителя;
|
||||||
- история результатов игрового вечера.
|
- история результатов игрового вечера.
|
||||||
|
|
||||||
@@ -79,7 +79,9 @@
|
|||||||
- пресеты регламентов;
|
- пресеты регламентов;
|
||||||
- статистика игроков и команд;
|
- статистика игроков и команд;
|
||||||
- повторная жеребьёвка с защитой от одинаковых составов;
|
- повторная жеребьёвка с защитой от одинаковых составов;
|
||||||
- Discord-уведомления;
|
- [x] Discord-анонс нового микса с `@everyone` и ссылкой на регистрацию;
|
||||||
|
- обновление анонса, напоминания, публикация составов и результатов;
|
||||||
|
- синхронизация временных Discord-ролей и командных каналов;
|
||||||
- импорт данных и резервное копирование.
|
- импорт данных и резервное копирование.
|
||||||
|
|
||||||
## Идеи после MVP
|
## Идеи после MVP
|
||||||
|
|||||||
@@ -246,6 +246,47 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
"200": { description: Live tournament bracket, content: { application/json: { schema: { $ref: "#/components/schemas/Tournament" } } } }
|
"200": { description: Live tournament bracket, content: { application/json: { schema: { $ref: "#/components/schemas/Tournament" } } } }
|
||||||
"404": { $ref: "#/components/responses/NotFound" }
|
"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:
|
/api/events/{eventId}/rsvps:
|
||||||
get:
|
get:
|
||||||
tags: [Events]
|
tags: [Events]
|
||||||
@@ -1012,7 +1053,7 @@ components:
|
|||||||
format: date-time
|
format: date-time
|
||||||
state:
|
state:
|
||||||
type: string
|
type: string
|
||||||
enum: [RegistrationOpen, RegistrationClosed, Balancing, RostersDraft, RostersConfirmed, Live, Completed, Cancelled]
|
enum: [RegistrationOpen, RegistrationClosed, Balancing, RostersDraft, RostersConfirmed, BracketDraft, Live, Completed, Cancelled]
|
||||||
version: { type: integer }
|
version: { type: integer }
|
||||||
rulesetId: { type: string }
|
rulesetId: { type: string }
|
||||||
activeSeriesId: { type: string }
|
activeSeriesId: { type: string }
|
||||||
@@ -1229,9 +1270,45 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
version:
|
version:
|
||||||
type: integer
|
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:
|
Tournament:
|
||||||
type: object
|
type: object
|
||||||
required: [id, eventId, name, winnerTeamId, teamIds, rounds]
|
required: [id, eventId, name, winnerTeamId, teamIds, matches, rounds, version]
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
@@ -1245,12 +1322,17 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
|
matches:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/BracketMatch"
|
||||||
rounds:
|
rounds:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/Series"
|
$ref: "#/components/schemas/Series"
|
||||||
|
version: { type: integer }
|
||||||
Problem:
|
Problem:
|
||||||
type: object
|
type: object
|
||||||
required: [error]
|
required: [error]
|
||||||
|
|||||||
Reference in New Issue
Block a user