Refactor bracket validation logic to support draft state
This commit updates the bracket validation process by introducing a new `ValidateDraft` method, allowing for incomplete edits in the draft state. The existing `Validate` method has been modified to enforce complete validation only upon confirmation. Additionally, new tests have been added to ensure that incomplete drafts are accepted until confirmation, while still rejecting invalid references. Documentation has been updated to reflect these changes in the API and architecture.
This commit is contained in:
@@ -485,7 +485,7 @@ func (s *Service) UpdateBracket(ctx context.Context, actor domain.Account, event
|
|||||||
return draft, err
|
return draft, err
|
||||||
}
|
}
|
||||||
draft.Matches, draft.Confirmed, draft.Version = matches, false, draft.Version+1
|
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
|
return draft, err
|
||||||
}
|
}
|
||||||
out, err := s.Store.SaveBracketDraft(ctx, draft, expectedVersion)
|
out, err := s.Store.SaveBracketDraft(ctx, draft, expectedVersion)
|
||||||
|
|||||||
@@ -81,7 +81,15 @@ func NewBracketDraft(eventID string, teamIDs []string) (*BracketDraft, error) {
|
|||||||
return draft, draft.Validate()
|
return draft, draft.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d BracketDraft) ValidateDraft() error {
|
||||||
|
return d.validate(false)
|
||||||
|
}
|
||||||
|
|
||||||
func (d BracketDraft) Validate() error {
|
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 {
|
if d.EventID == "" || len(d.TeamIDs) < 2 || len(d.Matches) == 0 {
|
||||||
return fmt.Errorf("%w: incomplete bracket", ErrInvalid)
|
return fmt.Errorf("%w: incomplete bracket", ErrInvalid)
|
||||||
}
|
}
|
||||||
@@ -114,23 +122,28 @@ func (d BracketDraft) Validate() error {
|
|||||||
for _, source := range []SlotSource{match.SlotA, match.SlotB} {
|
for _, source := range []SlotSource{match.SlotA, match.SlotB} {
|
||||||
switch source.Kind {
|
switch source.Kind {
|
||||||
case SlotTeam:
|
case SlotTeam:
|
||||||
if !teams[source.TeamID] || source.MatchID != "" {
|
if source.MatchID != "" || (source.TeamID != "" && !teams[source.TeamID]) {
|
||||||
return fmt.Errorf("%w: unknown team source", ErrInvalid)
|
return fmt.Errorf("%w: unknown team source", ErrInvalid)
|
||||||
}
|
}
|
||||||
|
if requireComplete && source.TeamID == "" {
|
||||||
|
return fmt.Errorf("%w: empty bracket slot", ErrInvalid)
|
||||||
|
}
|
||||||
case SlotWinner, SlotLoser:
|
case SlotWinner, SlotLoser:
|
||||||
upstream, ok := matches[source.MatchID]
|
upstream, ok := matches[source.MatchID]
|
||||||
if !ok || upstream.Round >= match.Round || source.TeamID != "" {
|
if !ok || upstream.Round >= match.Round || source.TeamID != "" {
|
||||||
return fmt.Errorf("%w: match source must reference an earlier round", ErrInvalid)
|
return fmt.Errorf("%w: match source must reference an earlier round", ErrInvalid)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
if requireComplete || source.Kind != "" || source.TeamID != "" || source.MatchID != "" {
|
||||||
return fmt.Errorf("%w: empty bracket slot", ErrInvalid)
|
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)
|
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 fmt.Errorf("%w: the last round must contain exactly one match", ErrInvalid)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -49,3 +49,34 @@ func TestBracketRejectsForwardReferencesAndMultipleFinals(t *testing.T) {
|
|||||||
t.Fatal("expected invalid graph")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ Application-слой управляет единым versioned workflow собы
|
|||||||
|
|
||||||
Ручное редактирование работает с серверным roster draft. Backend разрешает обмен только между одинаковыми ролевыми слотами и замену слота игроком из резерва, после чего пересчитывает средние рейтинги и метрики. Подтверждение требует полного состава 1/2/2, уникальных игроков и капитана внутри каждой команды.
|
Ручное редактирование работает с серверным 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.
|
||||||
|
|
||||||
### Драфт как конечный автомат
|
### Драфт как конечный автомат
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ Mixmaker является источником истины только для
|
|||||||
|
|
||||||
### Tournament
|
### Tournament
|
||||||
|
|
||||||
Versioned граф матчей, в котором каждый слот ссылается на конкретную Team либо Winner/Loser матча из предыдущей колонки. Готовые зависимости материализуются в отдельные Bo3-серии автоматически; несколько готовых матчей могут идти параллельно. Последняя колонка содержит один матч, победитель которого становится общим победителем. Для трёх команд стартовый шаблон проводит `A–B`, затем `Loser(M1)–C`, затем финал победителей первых двух матчей.
|
Versioned граф матчей, в котором каждый слот ссылается на конкретную Team либо Winner/Loser матча из предыдущей колонки. Во время редактирования сохраняются промежуточные состояния с пустыми слотами, повторяющимися командами и несколькими матчами в последней колонке; полные инварианты применяются при подтверждении сетки. Готовые зависимости материализуются в отдельные Bo3-серии автоматически; несколько готовых матчей могут идти параллельно. Подтверждённая последняя колонка содержит один матч, победитель которого становится общим победителем. Для трёх команд стартовый шаблон проводит `A–B`, затем `Loser(M1)–C`, затем финал победителей первых двух матчей.
|
||||||
|
|
||||||
## Правила текущего регламента
|
## Правила текущего регламента
|
||||||
|
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ paths:
|
|||||||
put:
|
put:
|
||||||
tags: [Tournaments]
|
tags: [Tournaments]
|
||||||
operationId: updateBracketDraft
|
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" }]
|
parameters: [{ $ref: "#/components/parameters/EventId" }]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@@ -283,6 +284,7 @@ paths:
|
|||||||
post:
|
post:
|
||||||
tags: [Tournaments]
|
tags: [Tournaments]
|
||||||
operationId: confirmBracketDraft
|
operationId: confirmBracketDraft
|
||||||
|
description: Validates the complete graph, including filled slots and exactly one final match, then marks it confirmed.
|
||||||
parameters: [{ $ref: "#/components/parameters/EventId" }]
|
parameters: [{ $ref: "#/components/parameters/EventId" }]
|
||||||
requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/VersionCommand" } } } }
|
requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/VersionCommand" } } } }
|
||||||
responses:
|
responses:
|
||||||
|
|||||||
Reference in New Issue
Block a user