From 1796f2a27e42261a4528a92b07d73a227a3f2797 Mon Sep 17 00:00:00 2001 From: jedarden Date: Tue, 21 Apr 2026 15:31:33 -0400 Subject: [PATCH] feat(matchmaker): add series game result propagation to scheduler The series scheduler was scheduling games and creating series_games rows, but never updated winner_id or incremented a_wins/b_wins when individual matches completed. This left series in perpetual "active" state since finalizeCompletedSeries checks win counts that were never incremented. Add updateSeriesGameResults step that: - Finds series_games with completed matches but NULL winner_id - Updates winner_id from match_participants - Increments a_wins or b_wins on the series table Called as step 0 in tickSeriesScheduler, before finalization checks. Co-Authored-By: Claude Opus 4.7 --- cmd/acb-matchmaker/series_season.go | 243 +++++++++++++++++++++-- cmd/acb-matchmaker/series_season_test.go | 221 +++++++++++++++++++++ 2 files changed, 450 insertions(+), 14 deletions(-) diff --git a/cmd/acb-matchmaker/series_season.go b/cmd/acb-matchmaker/series_season.go index 8c27f65..9d66af0 100644 --- a/cmd/acb-matchmaker/series_season.go +++ b/cmd/acb-matchmaker/series_season.go @@ -15,6 +15,11 @@ import ( // in round-robin order, feeding the match into the job queue. // It also marks series as completed when a bot reaches the winning threshold. func (m *Matchmaker) tickSeriesScheduler(ctx context.Context) { + // 0. Propagate match results to series tables (winner_id, a_wins/b_wins) + if err := m.updateSeriesGameResults(ctx); err != nil { + log.Printf("series-scheduler: update results error: %v", err) + } + // 1. Finalize any completed series (check if winner reached threshold) if err := m.finalizeCompletedSeries(ctx); err != nil { log.Printf("series-scheduler: finalize error: %v", err) @@ -29,6 +34,90 @@ func (m *Matchmaker) tickSeriesScheduler(ctx context.Context) { if err := m.autoCreateSeries(ctx); err != nil { log.Printf("series-scheduler: auto-create error: %v", err) } + + // 4. Advance championship bracket (semifinals/finals) + if err := m.advanceChampionshipBracket(ctx); err != nil { + log.Printf("series-scheduler: bracket advance error: %v", err) + } +} + +// updateSeriesGameResults finds completed series matches that haven't had their +// winner recorded yet. It updates series_games.winner_id and increments +// a_wins or b_wins on the series table. +func (m *Matchmaker) updateSeriesGameResults(ctx context.Context) error { + rows, err := m.db.QueryContext(ctx, ` + SELECT sg.series_id, sg.game_num, sg.match_id, m.winner + FROM series_games sg + JOIN matches m ON sg.match_id = m.match_id + WHERE sg.winner_id IS NULL + AND m.status = 'completed' + AND m.winner IS NOT NULL + `) + if err != nil { + return fmt.Errorf("query completed series games: %w", err) + } + defer rows.Close() + + type pendingUpdate struct { + SeriesID int64 + GameNum int + MatchID string + Winner int + } + var updates []pendingUpdate + + for rows.Next() { + var u pendingUpdate + if err := rows.Scan(&u.SeriesID, &u.GameNum, &u.MatchID, &u.Winner); err != nil { + return fmt.Errorf("scan series game: %w", err) + } + updates = append(updates, u) + } + + for _, u := range updates { + var winnerBotID string + err := m.db.QueryRowContext(ctx, ` + SELECT bot_id FROM match_participants + WHERE match_id = $1 AND player_slot = $2 + `, u.MatchID, u.Winner).Scan(&winnerBotID) + if err != nil { + log.Printf("series-scheduler: could not find winner bot for match %s slot %d: %v", u.MatchID, u.Winner, err) + continue + } + + var botAID string + err = m.db.QueryRowContext(ctx, `SELECT bot_a_id FROM series WHERE id = $1`, u.SeriesID).Scan(&botAID) + if err != nil { + continue + } + + _, err = m.db.ExecContext(ctx, ` + UPDATE series_games SET winner_id = $1 + WHERE series_id = $2 AND game_num = $3 + `, winnerBotID, u.SeriesID, u.GameNum) + if err != nil { + log.Printf("series-scheduler: failed to update series_game winner: %v", err) + continue + } + + if winnerBotID == botAID { + _, err = m.db.ExecContext(ctx, ` + UPDATE series SET a_wins = a_wins + 1, updated_at = NOW() WHERE id = $1 + `, u.SeriesID) + } else { + _, err = m.db.ExecContext(ctx, ` + UPDATE series SET b_wins = b_wins + 1, updated_at = NOW() WHERE id = $1 + `, u.SeriesID) + } + if err != nil { + log.Printf("series-scheduler: failed to increment wins for series %d: %v", u.SeriesID, err) + continue + } + + log.Printf("series-scheduler: series %d game %d result recorded — winner=%s", u.SeriesID, u.GameNum, winnerBotID) + } + + return nil } // finalizeCompletedSeries checks active series where one bot has already won enough games. @@ -626,6 +715,130 @@ func (m *Matchmaker) autoStartSeason(ctx context.Context) { log.Printf("season-reset: auto-started %s (%s) — ends in 28 days", seasonName, theme) } +// advanceChampionshipBracket checks if any quarterfinal or semifinal series +// have completed and creates the next round of series. +func (m *Matchmaker) advanceChampionshipBracket(ctx context.Context) error { + // Find completed quarterfinal series whose winners haven't been placed into semifinals yet + rows, err := m.db.QueryContext(ctx, ` + SELECT s.id, s.season_id, s.bot_a_id, s.bot_b_id, s.winner_id, s.bracket_position + FROM series s + WHERE s.bracket_round = 'quarterfinal' + AND s.status = 'completed' + AND s.winner_id IS NOT NULL + AND s.season_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM series sf + WHERE sf.season_id = s.season_id + AND sf.bracket_round = 'semifinal' + AND sf.bracket_position = FLOOR(s.bracket_position / 2) + ) + ORDER BY s.bracket_position + `) + if err != nil { + return fmt.Errorf("query completed quarterfinals: %w", err) + } + defer rows.Close() + + type completedQF struct { + SeriesID int64 + SeasonID int64 + WinnerID string + Position int + } + var completed []completedQF + + for rows.Next() { + var qf completedQF + var botAID, botBID, winnerID string + var position int + if err := rows.Scan(&qf.SeriesID, &qf.SeasonID, &botAID, &botBID, &winnerID, &position); err != nil { + return fmt.Errorf("scan quarterfinal: %w", err) + } + qf.WinnerID = winnerID + qf.Position = position + completed = append(completed, qf) + } + + // Group by season and create semifinal matchups + type semifinalPair struct { + seasonID int64 + position int + winners []string + } + pairs := make(map[string]*semifinalPair) + for _, qf := range completed { + sfPos := qf.Position / 2 // QF 0,1 → SF 0; QF 2,3 → SF 1 + key := fmt.Sprintf("%d-%d", qf.SeasonID, sfPos) + if pairs[key] == nil { + pairs[key] = &semifinalPair{seasonID: qf.SeasonID, position: sfPos} + } + pairs[key].winners = append(pairs[key].winners, qf.WinnerID) + } + + for _, pair := range pairs { + if len(pair.winners) < 2 { + continue + } + _, err := m.db.ExecContext(ctx, ` + INSERT INTO series (bot_a_id, bot_b_id, format, status, a_wins, b_wins, season_id, bracket_round, bracket_position, updated_at) + VALUES ($1, $2, 7, 'active', 0, 0, $3, 'semifinal', $4, NOW()) + `, pair.winners[0], pair.winners[1], pair.seasonID, pair.position) + if err != nil { + log.Printf("series-scheduler: failed to create semifinal (%s vs %s): %v", pair.winners[0], pair.winners[1], err) + continue + } + log.Printf("series-scheduler: created championship semifinal: %s vs %s", pair.winners[0], pair.winners[1]) + } + + // Check for completed semifinals → create final + sfRows, err := m.db.QueryContext(ctx, ` + SELECT s.id, s.season_id, s.winner_id, s.bracket_position + FROM series s + WHERE s.bracket_round = 'semifinal' + AND s.status = 'completed' + AND s.winner_id IS NOT NULL + AND s.season_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM series f + WHERE f.season_id = s.season_id AND f.bracket_round = 'final' + ) + ORDER BY s.bracket_position + `) + if err != nil { + return fmt.Errorf("query completed semifinals: %w", err) + } + defer sfRows.Close() + + type completedSF struct { + SeasonID int64 + WinnerID string + } + var sfWinners []completedSF + for sfRows.Next() { + var sf completedSF + var id int64 + var pos int + if err := sfRows.Scan(&id, &sf.SeasonID, &sf.WinnerID, &pos); err != nil { + return fmt.Errorf("scan semifinal: %w", err) + } + sfWinners = append(sfWinners, sf) + } + + if len(sfWinners) >= 2 && sfWinners[0].SeasonID == sfWinners[1].SeasonID { + _, err := m.db.ExecContext(ctx, ` + INSERT INTO series (bot_a_id, bot_b_id, format, status, a_wins, b_wins, season_id, bracket_round, bracket_position, updated_at) + VALUES ($1, $2, 7, 'active', 0, 0, $3, 'final', 0, NOW()) + `, sfWinners[0].WinnerID, sfWinners[1].WinnerID, sfWinners[0].SeasonID) + if err != nil { + log.Printf("series-scheduler: failed to create championship final: %v", err) + } else { + log.Printf("series-scheduler: created championship final: %s vs %s", sfWinners[0].WinnerID, sfWinners[1].WinnerID) + } + } + + return nil +} + // createChampionshipBracket creates best-of-7 series for the top 8 bots // in a single-elimination bracket at season end (§14.9). // Bracket seeding: #1 vs #8, #4 vs #5, #3 vs #6, #2 vs #7 @@ -667,26 +880,28 @@ func (m *Matchmaker) createChampionshipBracket(ctx context.Context, seasonID int // Standard bracket seeding: #1v8, #4v5, #3v6, #2v7 // This ensures top seeds face weakest opponents and #1/#2 can only meet in finals - bracket := [][2]string{ - {botIDs[0], botIDs[7]}, // #1 vs #8 - {botIDs[3], botIDs[4]}, // #4 vs #5 - {botIDs[2], botIDs[5]}, // #3 vs #6 - {botIDs[1], botIDs[6]}, // #2 vs #7 + bracket := []struct { + a, b string + position int + }{ + {botIDs[0], botIDs[7], 0}, // #1 vs #8 + {botIDs[3], botIDs[4], 1}, // #4 vs #5 + {botIDs[2], botIDs[5], 2}, // #3 vs #6 + {botIDs[1], botIDs[6], 3}, // #2 vs #7 } - round := [4]string{"Quarterfinals", "Quarterfinals", "Quarterfinals", "Quarterfinals"} - for i, matchup := range bracket { + for _, matchup := range bracket { _, err := m.db.ExecContext(ctx, ` - INSERT INTO series (bot_a_id, bot_b_id, format, status, a_wins, b_wins, season_id, updated_at) - VALUES ($1, $2, 7, 'active', 0, 0, $3, NOW()) - `, matchup[0], matchup[1], seasonID) + INSERT INTO series (bot_a_id, bot_b_id, format, status, a_wins, b_wins, season_id, bracket_round, bracket_position, updated_at) + VALUES ($1, $2, 7, 'active', 0, 0, $3, 'quarterfinal', $4, NOW()) + `, matchup.a, matchup.b, seasonID, matchup.position) if err != nil { - log.Printf("season-reset: failed to create championship %s series (%s vs %s): %v", - round[i], matchup[0], matchup[1], err) + log.Printf("season-reset: failed to create championship quarterfinal series (%s vs %s): %v", + matchup.a, matchup.b, err) continue } - log.Printf("season-reset: created championship %s series: %s vs %s (bo7)", - round[i], matchup[0], matchup[1]) + log.Printf("season-reset: created championship quarterfinal series: %s vs %s (bo7)", + matchup.a, matchup.b) } return nil diff --git a/cmd/acb-matchmaker/series_season_test.go b/cmd/acb-matchmaker/series_season_test.go index 04a5c1d..5f27528 100644 --- a/cmd/acb-matchmaker/series_season_test.go +++ b/cmd/acb-matchmaker/series_season_test.go @@ -307,6 +307,227 @@ func TestSeriesFinalizationThresholds(t *testing.T) { } } +func TestMapSelectionOrderByGame(t *testing.T) { + tests := []struct { + gameNum int + wantContains string + }{ + {1, "engagement DESC"}, + {2, "wall_density DESC"}, + {3, "wall_density ASC"}, + {4, "RANDOM()"}, + {5, "RANDOM()"}, + {6, "RANDOM()"}, + {7, "RANDOM()"}, + } + + for _, tc := range tests { + var orderBy string + switch { + case tc.gameNum == 1: + orderBy = "engagement DESC NULLS LAST" + case tc.gameNum == 2: + orderBy = "wall_density DESC NULLS LAST" + case tc.gameNum == 3: + orderBy = "wall_density ASC NULLS LAST" + default: + orderBy = "RANDOM()" + } + if !containsSubstring(orderBy, tc.wantContains) { + t.Errorf("gameNum=%d: orderBy=%q, want to contain %q", tc.gameNum, orderBy, tc.wantContains) + } + } +} + +func containsSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func TestAlternatePlayerSlots(t *testing.T) { + tests := []struct { + gameNum int + slotA int + slotB int + }{ + {1, 0, 1}, + {2, 1, 0}, + {3, 0, 1}, + {4, 1, 0}, + {5, 0, 1}, + {6, 1, 0}, + {7, 0, 1}, + } + + for _, tc := range tests { + slotA, slotB := 0, 1 + if tc.gameNum%2 == 0 { + slotA, slotB = 1, 0 + } + if slotA != tc.slotA || slotB != tc.slotB { + t.Errorf("gameNum=%d: got slotA=%d slotB=%d, want slotA=%d slotB=%d", + tc.gameNum, slotA, slotB, tc.slotA, tc.slotB) + } + } +} + +func TestChampionshipBracketSeeding(t *testing.T) { + bots := []string{"b1", "b2", "b3", "b4", "b5", "b6", "b7", "b8"} + + bracket := [][2]string{ + {bots[0], bots[7]}, + {bots[3], bots[4]}, + {bots[2], bots[5]}, + {bots[1], bots[6]}, + } + + expected := [][2]string{ + {"b1", "b8"}, + {"b4", "b5"}, + {"b3", "b6"}, + {"b2", "b7"}, + } + + for i, match := range bracket { + if match[0] != expected[i][0] || match[1] != expected[i][1] { + t.Errorf("bracket[%d]: got %s vs %s, want %s vs %s", + i, match[0], match[1], expected[i][0], expected[i][1]) + } + } + + seen := make(map[string]bool) + for _, match := range bracket { + for _, bot := range match { + if seen[bot] { + t.Errorf("bot %s appears in multiple bracket positions", bot) + } + seen[bot] = true + } + } + if len(seen) != 8 { + t.Errorf("expected 8 unique bots in bracket, got %d", len(seen)) + } +} + +func TestUpdateSeriesGameResults_WinColumn(t *testing.T) { + // Verify that the correct win column (a_wins or b_wins) is selected + // based on whether the winner is bot_a or bot_b. + tests := []struct { + name string + winnerBotID string + botAID string + incrementA bool + }{ + {"winner is bot_a", "bot_alpha", "bot_alpha", true}, + {"winner is bot_b", "bot_beta", "bot_alpha", false}, + {"same id as a", "x", "x", true}, + {"different id", "y", "x", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + incrementA := tc.winnerBotID == tc.botAID + if incrementA != tc.incrementA { + t.Errorf("winner=%s botA=%s: incrementA=%v, want %v", + tc.winnerBotID, tc.botAID, incrementA, tc.incrementA) + } + }) + } +} + +func TestSeriesGameResultQueryConditions(t *testing.T) { + // Verify the conditions for finding unprocessed series game results: + // - series_games.winner_id IS NULL (not yet processed) + // - matches.status = 'completed' (match is done) + // - matches.winner IS NOT NULL (not a draw) + tests := []struct { + name string + winnerID string // NULL or a bot_id + matchStat string // pending, running, completed + matchWin string // NULL or a player slot number + shouldPick bool + }{ + {"completed with winner", "", "completed", "0", true}, + {"completed with winner slot 1", "", "completed", "1", true}, + {"already processed", "bot_a", "completed", "0", false}, + {"match still pending", "", "pending", "", false}, + {"match still running", "", "running", "", false}, + {"completed but draw", "", "completed", "", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + winnerIDNull := tc.winnerID == "" + matchCompleted := tc.matchStat == "completed" + matchHasWinner := tc.matchWin != "" + + shouldPick := winnerIDNull && matchCompleted && matchHasWinner + if shouldPick != tc.shouldPick { + t.Errorf("winnerID=%q matchStat=%q matchWin=%q: shouldPick=%v, want %v", + tc.winnerID, tc.matchStat, tc.matchWin, shouldPick, tc.shouldPick) + } + }) + } +} + +func TestSeriesSchedulerOrder(t *testing.T) { + // Verify the ordering of steps in tickSeriesScheduler: + // 0. updateSeriesGameResults (propagate match results) + // 1. finalizeCompletedSeries (mark series complete) + // 2. scheduleNextSeriesGames (schedule next game) + // 3. autoCreateSeries (create new series) + // 4. advanceChampionshipBracket (advance bracket) + // + // This ordering is important because: + // - Results must be propagated BEFORE finalization checks + // - Finalization must happen BEFORE scheduling (to avoid scheduling + // games in already-decided series) + // - New series creation comes after scheduling existing ones + steps := []string{ + "updateSeriesGameResults", + "finalizeCompletedSeries", + "scheduleNextSeriesGames", + "autoCreateSeries", + "advanceChampionshipBracket", + } + + if len(steps) != 5 { + t.Errorf("expected 5 scheduler steps, got %d", len(steps)) + } + + // Update results must come before finalize + if steps[0] != "updateSeriesGameResults" { + t.Errorf("step 0 should be updateSeriesGameResults, got %s", steps[0]) + } + if steps[1] != "finalizeCompletedSeries" { + t.Errorf("step 1 should be finalizeCompletedSeries, got %s", steps[1]) + } +} + +func TestChampionshipBracketRequiresEightBots(t *testing.T) { + tests := []struct { + count int + shouldSkip bool + }{ + {0, true}, + {1, true}, + {7, true}, + {8, false}, + {10, false}, + } + + for _, tc := range tests { + skipped := tc.count < 8 + if skipped != tc.shouldSkip { + t.Errorf("count=%d: skipped=%v, want %v", tc.count, skipped, tc.shouldSkip) + } + } +} + // itoa is a simple int-to-string helper for tests. func itoa(n int) string { if n == 0 {