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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-21 15:31:33 -04:00
parent 51edf35d22
commit 1796f2a27e
2 changed files with 450 additions and 14 deletions

View file

@ -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

View file

@ -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 {