diff --git a/backend/internal/application/workflow.go b/backend/internal/application/workflow.go index 522a943..e8b1350 100644 --- a/backend/internal/application/workflow.go +++ b/backend/internal/application/workflow.go @@ -485,7 +485,7 @@ func (s *Service) UpdateBracket(ctx context.Context, actor domain.Account, event return draft, err } draft.Matches, draft.Confirmed, draft.Version = matches, false, draft.Version+1 - if err = draft.Validate(); err != nil { + if err = draft.ValidateDraft(); err != nil { return draft, err } out, err := s.Store.SaveBracketDraft(ctx, draft, expectedVersion) diff --git a/backend/internal/domain/bracket.go b/backend/internal/domain/bracket.go index d1a0928..79273e6 100644 --- a/backend/internal/domain/bracket.go +++ b/backend/internal/domain/bracket.go @@ -81,7 +81,15 @@ func NewBracketDraft(eventID string, teamIDs []string) (*BracketDraft, error) { return draft, draft.Validate() } +func (d BracketDraft) ValidateDraft() error { + return d.validate(false) +} + func (d BracketDraft) Validate() error { + return d.validate(true) +} + +func (d BracketDraft) validate(requireComplete bool) error { if d.EventID == "" || len(d.TeamIDs) < 2 || len(d.Matches) == 0 { return fmt.Errorf("%w: incomplete bracket", ErrInvalid) } @@ -114,23 +122,28 @@ func (d BracketDraft) Validate() error { for _, source := range []SlotSource{match.SlotA, match.SlotB} { switch source.Kind { case SlotTeam: - if !teams[source.TeamID] || source.MatchID != "" { + if source.MatchID != "" || (source.TeamID != "" && !teams[source.TeamID]) { return fmt.Errorf("%w: unknown team source", ErrInvalid) } + if requireComplete && source.TeamID == "" { + return fmt.Errorf("%w: empty bracket slot", 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 requireComplete || source.Kind != "" || source.TeamID != "" || source.MatchID != "" { + return fmt.Errorf("%w: empty bracket slot", ErrInvalid) + } } } - if match.SlotA.Kind == SlotTeam && match.SlotB.Kind == SlotTeam && match.SlotA.TeamID == match.SlotB.TeamID { + if requireComplete && 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 { + if requireComplete && finals != 1 { return fmt.Errorf("%w: the last round must contain exactly one match", ErrInvalid) } return nil diff --git a/backend/internal/domain/bracket_test.go b/backend/internal/domain/bracket_test.go index 72d1b48..e02eee2 100644 --- a/backend/internal/domain/bracket_test.go +++ b/backend/internal/domain/bracket_test.go @@ -49,3 +49,34 @@ func TestBracketRejectsForwardReferencesAndMultipleFinals(t *testing.T) { t.Fatal("expected invalid graph") } } + +func TestBracketDraftAllowsIncompleteEditsUntilConfirmation(t *testing.T) { + draft := BracketDraft{ + EventID: "event", TeamIDs: []string{"a", "b"}, + Matches: []BracketMatch{ + {ID: "m1", Round: 0, SlotA: SlotSource{Kind: SlotTeam, TeamID: "a"}, SlotB: SlotSource{Kind: SlotTeam, TeamID: "b"}}, + {ID: "m2", Round: 1, SlotA: SlotSource{Kind: SlotWinner, MatchID: "m1"}, SlotB: SlotSource{Kind: SlotTeam}}, + {ID: "m3", Round: 1, Order: 1, SlotA: SlotSource{Kind: SlotTeam, TeamID: "a"}, SlotB: SlotSource{Kind: SlotTeam, TeamID: "a"}}, + }, + } + + if err := draft.ValidateDraft(); err != nil { + t.Fatalf("incomplete editable draft was rejected: %v", err) + } + if err := draft.Validate(); err == nil { + t.Fatal("expected incomplete bracket to be rejected at confirmation") + } +} + +func TestBracketDraftStillRejectsInvalidReferences(t *testing.T) { + draft := BracketDraft{ + EventID: "event", TeamIDs: []string{"a", "b"}, + Matches: []BracketMatch{ + {ID: "m1", Round: 0, SlotA: SlotSource{Kind: SlotWinner, MatchID: "missing"}, SlotB: SlotSource{Kind: SlotTeam}}, + }, + } + + if err := draft.ValidateDraft(); err == nil { + t.Fatal("expected invalid match reference to be rejected") + } +} diff --git a/memory_bank/architecture.md b/memory_bank/architecture.md index 1c4aa53..3654d77 100644 --- a/memory_bank/architecture.md +++ b/memory_bank/architecture.md @@ -88,7 +88,7 @@ Application-слой управляет единым versioned workflow собы Ручное редактирование работает с серверным roster draft. Backend разрешает обмен только между одинаковыми ролевыми слотами и замену слота игроком из резерва, после чего пересчитывает средние рейтинги и метрики. Подтверждение требует полного состава 1/2/2, уникальных игроков и капитана внутри каждой команды. -После подтверждения составов workflow переходит в versioned `BracketDraft`. Staff собирает DAG матчей из источников Team / Winner / Loser; ссылки разрешены только на предыдущие колонки, а в последней колонке должен быть один финальный матч. `start-scrim` материализует все готовые Bo3, а завершение серии разрешает зависимости и атомарно создаёт следующие матчи. Поэтому одна колонка может содержать параллельные серии, а три команды могут играть последовательную ротацию через проигравшего. Tournament read-model гидратируется свежими версиями Series. Умный event-level live-вход направляет участника в его матч, капитана/staff — к доступным командам, а остальных — в spectator mode. +После подтверждения составов workflow переходит в versioned `BracketDraft`. Staff собирает DAG матчей из источников Team / Winner / Loser; API обновления сохраняет структурно корректные промежуточные черновики с незаполненными слотами, а полную проверку команд, матчей и единственного финала выполняет команда подтверждения. Ссылки Winner / Loser всегда разрешены только на предыдущие колонки. `start-scrim` материализует все готовые Bo3, а завершение серии разрешает зависимости и атомарно создаёт следующие матчи. Поэтому одна колонка может содержать параллельные серии, а три команды могут играть последовательную ротацию через проигравшего. Tournament read-model гидратируется свежими версиями Series. Умный event-level live-вход направляет участника в его матч, капитана/staff — к доступным командам, а остальных — в spectator mode. ### Драфт как конечный автомат diff --git a/memory_bank/domain_model.md b/memory_bank/domain_model.md index bcea691..a55f95e 100644 --- a/memory_bank/domain_model.md +++ b/memory_bank/domain_model.md @@ -90,7 +90,7 @@ Mixmaker является источником истины только для ### Tournament -Versioned граф матчей, в котором каждый слот ссылается на конкретную Team либо Winner/Loser матча из предыдущей колонки. Готовые зависимости материализуются в отдельные Bo3-серии автоматически; несколько готовых матчей могут идти параллельно. Последняя колонка содержит один матч, победитель которого становится общим победителем. Для трёх команд стартовый шаблон проводит `A–B`, затем `Loser(M1)–C`, затем финал победителей первых двух матчей. +Versioned граф матчей, в котором каждый слот ссылается на конкретную Team либо Winner/Loser матча из предыдущей колонки. Во время редактирования сохраняются промежуточные состояния с пустыми слотами, повторяющимися командами и несколькими матчами в последней колонке; полные инварианты применяются при подтверждении сетки. Готовые зависимости материализуются в отдельные Bo3-серии автоматически; несколько готовых матчей могут идти параллельно. Подтверждённая последняя колонка содержит один матч, победитель которого становится общим победителем. Для трёх команд стартовый шаблон проводит `A–B`, затем `Loser(M1)–C`, затем финал победителей первых двух матчей. ## Правила текущего регламента diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index a26b862..dc9b30d 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -256,6 +256,7 @@ paths: put: tags: [Tournaments] operationId: updateBracketDraft + description: Saves a structurally valid work-in-progress graph; slots and final layout may remain incomplete until confirmation. parameters: [{ $ref: "#/components/parameters/EventId" }] requestBody: required: true @@ -283,6 +284,7 @@ paths: post: tags: [Tournaments] operationId: confirmBracketDraft + description: Validates the complete graph, including filled slots and exactly one final match, then marks it confirmed. parameters: [{ $ref: "#/components/parameters/EventId" }] requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/VersionCommand" } } } } responses: