Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
20a23c5
fix(infra): decouple WS lifetime from request timeout, fix rate-limit…
Jun 30, 2026
95cd5a3
fix(db): use partial unique indexes so soft-deleted rows free their s…
Jun 30, 2026
821007f
fix(scoring-ui): disable keyboard scoring during game/match-over conf…
Jun 30, 2026
a98f168
fix(overlay-ui): per-run WS cancellation + predicate cache write
Jun 30, 2026
1f71eff
fix(league/standings): forfeits, capacity, overrides, queue order, se…
Jun 30, 2026
0f00186
fix(auth): close Logto role-elevation gaps and revoke API keys for in…
Jun 30, 2026
295ad34
fix(tournaments-ui): announcement cache invalidation, full-division b…
Jun 30, 2026
d6daaf5
fix(components): Bearer-auth uploads, by-id venue lookup, MapView XSS…
Jun 30, 2026
eecd15c
fix(navigation): route dashboard/search/division links through sport-…
Jun 30, 2026
6046727
fix(handlers/security): close upload XSS, authz gaps, IDORs, and publ…
Jun 30, 2026
a96ad0a
fix(scoring/bracket): correct LB wiring, set-aware match-over, undo, …
Jun 30, 2026
383fd76
merge B2-match-engine-bracket
Jun 30, 2026
1daf9a6
merge B6-sql-migrations
Jun 30, 2026
1b29691
merge B7-fe-scoring
Jun 30, 2026
afff6c9
merge B8-fe-overlay
Jun 30, 2026
f76eeb9
merge B9-fe-tourn
Jun 30, 2026
9f13e02
merge B10-fe-nav
Jun 30, 2026
daae898
merge B11-fe-infra
Jun 30, 2026
4e5a5f3
merge B5-infra-config
Jun 30, 2026
dda9b93
merge B1-auth-logto
Jun 30, 2026
f0e2ee5
merge B3-league-standings
Jun 30, 2026
3fcf950
merge B4-handlers-security
Jun 30, 2026
1d4659b
fix(scoring): per-run cancellation in useMatchWebSocket to kill recon…
Jul 1, 2026
4c516ad
fix(authz): gate registration mutations + court update/delete ownership
Jul 1, 2026
dfe849a
merge fix/residual-be (registration auth + court ownership)
Jul 1, 2026
d7b1786
merge fix/residual-fe (useMatchWebSocket race)
Jul 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 41 additions & 17 deletions api/bracket/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,9 @@ func GenerateDoubleElimination(entries []SeedEntry) ([]BracketMatch, error) {
loserMatchesInFirstRound := losersInRound / 2

var prevLoserMatches []int
// Track the index (into loserMatches) of each match, grouped by losers
// round, so we can wire the internal progression round-by-round below.
var loserRoundIdx [][]int

for lRound := 1; lRound <= numLoserRounds; lRound++ {
var matchesThisRound int
Expand All @@ -221,6 +224,7 @@ func GenerateDoubleElimination(entries []SeedEntry) ([]BracketMatch, error) {
}

var currentLoserMatches []int
var currentRoundIdx []int
for i := 0; i < matchesThisRound; i++ {
matchNum++
lm := BracketMatch{
Expand All @@ -229,31 +233,44 @@ func GenerateDoubleElimination(entries []SeedEntry) ([]BracketMatch, error) {
RoundName: fmt.Sprintf("Losers Round %d", lRound),
}

// Wire losers bracket matches to next losers bracket match
if lRound < numLoserRounds {
// Will be calculated after all matches are created
}

currentRoundIdx = append(currentRoundIdx, len(loserMatches))
currentLoserMatches = append(currentLoserMatches, matchNum)
loserMatches = append(loserMatches, lm)
}
prevLoserMatches = currentLoserMatches
loserRoundIdx = append(loserRoundIdx, currentRoundIdx)
}

// Wire losers bracket internal progression
allLoserMatches := loserMatches
for i := range allLoserMatches {
if i < len(allLoserMatches)-1 {
// Simple sequential wiring for losers bracket
nextIdx := (i / 2) + len(winnerMatches) + len(allLoserMatches[:i+1])
if nextIdx < len(winnerMatches)+len(allLoserMatches) {
nextMatchNum := allLoserMatches[nextIdx-len(winnerMatches)].MatchNumber
allLoserMatches[i].NextMatchNumber = nextMatchNum
// Wire losers bracket internal progression round-by-round. Each match in
// losers round r feeds its winner into a match in losers round r+1; the
// last losers round's match is the LB final and is wired to grand finals
// separately below.
//
// Slot convention (chosen so an LB survivor never collides with a winners
// bracket dropout landing in the same destination):
// - When the next round halves the field (minor round: survivors meet
// each other), match i maps to match i/2 with slot from parity.
// - When the next round keeps the same match count (major round:
// survivor meets an incoming winners bracket dropout), match i maps to
// match i and the survivor always takes slot 1, reserving slot 2 for
// the dropout.
for r := 0; r < len(loserRoundIdx)-1; r++ {
curr := loserRoundIdx[r]
next := loserRoundIdx[r+1]
halving := len(next) < len(curr)
for i, idx := range curr {
if halving {
destIdx := next[i/2]
loserMatches[idx].NextMatchNumber = loserMatches[destIdx].MatchNumber
if i%2 == 0 {
allLoserMatches[i].NextMatchSlot = 1
loserMatches[idx].NextMatchSlot = 1
} else {
allLoserMatches[i].NextMatchSlot = 2
loserMatches[idx].NextMatchSlot = 2
}
} else {
destIdx := next[i]
loserMatches[idx].NextMatchNumber = loserMatches[destIdx].MatchNumber
loserMatches[idx].NextMatchSlot = 1
}
}
}
Expand Down Expand Up @@ -286,11 +303,18 @@ func GenerateDoubleElimination(entries []SeedEntry) ([]BracketMatch, error) {
wbFinals.NextMatchNumber = matchNum
wbFinals.NextMatchSlot = 1

// Wire LB finals winner to grand finals slot 2
// Wire grand finals slot 2: normally fed by the LB final winner. With only a
// single winners-bracket round (e.g. exactly 2 teams) there are no losers
// bracket matches, so the WB final's loser is the "losers bracket" and must
// feed grand finals slot 2 directly. Without this, slot 2 can never be
// filled and the event cannot complete.
if len(loserMatches) > 0 {
lbFinals := &loserMatches[len(loserMatches)-1]
lbFinals.NextMatchNumber = matchNum
lbFinals.NextMatchSlot = 2
} else {
wbFinals.LoserNextMatchNumber = matchNum
wbFinals.LoserNextMatchSlot = 2
}

// Combine all matches
Expand Down
102 changes: 102 additions & 0 deletions api/bracket/generator_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package bracket

import (
"fmt"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -147,6 +148,107 @@ func TestGenerateDoubleElimination_TooFewEntries(t *testing.T) {
assert.Error(t, err)
}

// TestGenerateDoubleElimination_LosersBracketWiring asserts that the internal
// losers-bracket progression is structurally valid for several bracket sizes:
// every non-final LB match points its winner forward into a later, existing LB
// match in a later round, exactly one LB match feeds grand finals, and no
// destination slot is targeted by more than one source.
func TestGenerateDoubleElimination_LosersBracketWiring(t *testing.T) {
for _, n := range []int{4, 8, 16} {
t.Run(roundsLabel(n), func(t *testing.T) {
matches, err := GenerateDoubleElimination(makeEntries(n))
require.NoError(t, err)

byNum := make(map[int]BracketMatch, len(matches))
for _, m := range matches {
byNum[m.MatchNumber] = m
}

grandFinals := matches[len(matches)-1]
require.Equal(t, "Grand Finals", grandFinals.RoundName)

// Collect losers-bracket matches in declared order.
var lbMatches []BracketMatch
for _, m := range matches {
if len(m.RoundName) >= 6 && m.RoundName[:6] == "Losers" {
lbMatches = append(lbMatches, m)
}
}
require.NotEmpty(t, lbMatches, "expected losers bracket matches for n=%d", n)

// Track (destMatch, slot) targets to detect collisions across both
// the LB-internal winner wiring and the WB-dropout loser wiring.
type target struct{ matchNum, slot int }
seen := make(map[target]int)
record := func(dst, slot int) {
seen[target{dst, slot}]++
}

lbFeedingGrandFinals := 0
for _, m := range lbMatches {
if m.NextMatchNumber == grandFinals.MatchNumber {
lbFeedingGrandFinals++
record(m.NextMatchNumber, m.NextMatchSlot)
continue
}
// Non-final LB match: must point at a later, existing match in a
// strictly later round.
require.NotZero(t, m.NextMatchNumber,
"LB match %d (%s) has no NextMatchNumber (orphaned)", m.MatchNumber, m.RoundName)
dst, ok := byNum[m.NextMatchNumber]
require.True(t, ok, "LB match %d points at non-existent match %d", m.MatchNumber, m.NextMatchNumber)
assert.Greater(t, dst.MatchNumber, m.MatchNumber,
"LB match %d should feed a later match, got %d", m.MatchNumber, dst.MatchNumber)
assert.Greater(t, dst.Round, m.Round,
"LB match %d should feed a later round", m.MatchNumber)
record(m.NextMatchNumber, m.NextMatchSlot)
}

assert.Equal(t, 1, lbFeedingGrandFinals, "exactly one LB match should feed grand finals")

// Include winners-bracket dropout targets so survivor/dropout
// collisions in shared LB matches would be caught.
for _, m := range matches {
if m.LoserNextMatchNumber != 0 {
record(m.LoserNextMatchNumber, m.LoserNextMatchSlot)
}
}

for tgt, count := range seen {
assert.LessOrEqual(t, count, 1,
"destination match %d slot %d targeted by %d sources (collision)", tgt.matchNum, tgt.slot, count)
}
})
}
}

// TestGenerateDoubleElimination_TwoTeams verifies the 2-team edge case: with no
// losers bracket, the winners-final loser must feed grand finals slot 2 so the
// final can be filled and the event can complete.
func TestGenerateDoubleElimination_TwoTeams(t *testing.T) {
matches, err := GenerateDoubleElimination(makeEntries(2))
require.NoError(t, err)

grandFinals := matches[len(matches)-1]
require.Equal(t, "Grand Finals", grandFinals.RoundName)

// Some match must feed grand finals slot 2.
slot2Fed := false
for _, m := range matches {
if m.NextMatchNumber == grandFinals.MatchNumber && m.NextMatchSlot == 2 {
slot2Fed = true
}
if m.LoserNextMatchNumber == grandFinals.MatchNumber && m.LoserNextMatchSlot == 2 {
slot2Fed = true
}
}
assert.True(t, slot2Fed, "grand finals slot 2 must have a feeder for a 2-team double elimination")
}

func roundsLabel(n int) string {
return fmt.Sprintf("n=%d", n)
}

func TestGenerateRoundRobin_4Teams(t *testing.T) {
entries := makeEntries(4)

Expand Down
14 changes: 11 additions & 3 deletions api/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,17 @@ func Load() (*Config, error) {
cfg.Env = "development"
}

origins := os.Getenv("CORS_ALLOWED_ORIGINS")
if origins != "" {
cfg.CORSAllowedOrigins = strings.Split(origins, ",")
// Normalize origins once at parse time so every consumer (the CORS
// middleware and the WebSocket origin allowlist) shares an identical,
// whitespace-free set. Trimming here makes the parity the ws.Handler doc
// comment claims actually hold even when the env var has spaces after
// commas (e.g. "https://a.com, https://b.com").
if origins := os.Getenv("CORS_ALLOWED_ORIGINS"); origins != "" {
for _, o := range strings.Split(origins, ",") {
if o = strings.TrimSpace(o); o != "" {
cfg.CORSAllowedOrigins = append(cfg.CORSAllowedOrigins, o)
}
}
}

return cfg, nil
Expand Down
2 changes: 1 addition & 1 deletion api/db/generated/matches.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions api/db/generated/registrations.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

79 changes: 79 additions & 0 deletions api/db/generated/tournaments.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading