From c56cc8bae6524dd914ecd2677b672240ca8d6a15 Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 22 Apr 2026 15:22:12 -0400 Subject: [PATCH] =?UTF-8?q?fix(matchmaker):=20multi-match=20crash=20cooldo?= =?UTF-8?q?wn=20(3=20strikes=20/=2030=20min)=20per=20=C2=A74.5=20+=20?= =?UTF-8?q?=C2=A76.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add crash_strikes and cooldown_until columns to bots table. Worker increments strikes on crash (cooldown at 3), resets on success. Matchmaker excludes cooldown bots from pairing, series scheduling, and championship brackets. Fix erroneous cooldown filter on series table in finalizeCompletedSeries (column only exists on bots). Co-Authored-By: Claude Opus 4.7 --- .needle-predispatch-sha | 2 +- cmd/acb-api/db.go | 4 +- cmd/acb-api/server.go | 4 +- .../internal/crosspoll/crosspoll.go | 16 ++++++- cmd/acb-matchmaker/series_season.go | 15 ++++-- cmd/acb-matchmaker/tickers.go | 6 ++- cmd/acb-worker/api.go | 9 ++-- cmd/acb-worker/db.go | 48 +++++++++++++++++++ engine/game.go | 46 ++++++++++-------- engine/match.go | 8 ++++ engine/turn.go | 40 ++++++++++++---- engine/types.go | 21 ++++---- web/src/app.ts | 9 ++++ web/src/components/playlist-carousel.ts | 14 ++++-- web/src/lib/virtual-list.ts | 1 + web/src/pages/bot-profile.ts | 10 ++-- web/src/pages/matches.ts | 5 ++ web/src/pages/playlists.ts | 2 +- 18 files changed, 196 insertions(+), 64 deletions(-) diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index 6ac18c3..f2b5262 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -30f2c63b20a8363b6d9b21829b8c7375dd127d8b +da824f736002b1e597d4c5c658c1122a1f3895b4 diff --git a/cmd/acb-api/db.go b/cmd/acb-api/db.go index 21e3ddb..f2854a1 100644 --- a/cmd/acb-api/db.go +++ b/cmd/acb-api/db.go @@ -163,7 +163,9 @@ CREATE TABLE IF NOT EXISTS bots ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_active TIMESTAMPTZ, consec_fails INTEGER NOT NULL DEFAULT 0, - archetype VARCHAR(64) + archetype VARCHAR(64), + crash_strikes INTEGER NOT NULL DEFAULT 0, + cooldown_until TIMESTAMPTZ ); CREATE TABLE IF NOT EXISTS matches ( diff --git a/cmd/acb-api/server.go b/cmd/acb-api/server.go index f047a09..f0cef15 100644 --- a/cmd/acb-api/server.go +++ b/cmd/acb-api/server.go @@ -485,7 +485,7 @@ func (s *Server) fetchReplayFromR2(ctx context.Context, matchID string) ([]byte, r2Endpoint = env } - url := fmt.Sprintf("%s/replays/%s.json", r2Endpoint, matchID) + url := fmt.Sprintf("%s/replays/%s.json.gz", r2Endpoint, matchID) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -514,7 +514,7 @@ func (s *Server) fetchReplayFromB2(ctx context.Context, matchID string) ([]byte, b2Endpoint = env } - url := fmt.Sprintf("%s/replays/%s.json", b2Endpoint, matchID) + url := fmt.Sprintf("%s/replays/%s.json.gz", b2Endpoint, matchID) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { diff --git a/cmd/acb-evolver/internal/crosspoll/crosspoll.go b/cmd/acb-evolver/internal/crosspoll/crosspoll.go index 4904cb0..fcc3260 100644 --- a/cmd/acb-evolver/internal/crosspoll/crosspoll.go +++ b/cmd/acb-evolver/internal/crosspoll/crosspoll.go @@ -27,10 +27,22 @@ type PollinationResult struct { TargetLang string } +// programStore abstracts the database operations needed by cross-pollination. +type programStore interface { + MaxGenerationByIsland(ctx context.Context) (map[string]int, error) + ListTopByIsland(ctx context.Context, island string, limit int) ([]*evolverdb.Program, error) + Create(ctx context.Context, p *evolverdb.Program) (int64, error) +} + +// llmGenerator abstracts the LLM client for code translation. +type llmGenerator interface { + Generate(ctx context.Context, req llm.GenerateRequest) (*llm.GenerateResponse, error) +} + // Checker determines which islands need cross-pollination and executes it. type Checker struct { - store *evolverdb.Store - client *llm.Client + store programStore + client llmGenerator rng *rand.Rand } diff --git a/cmd/acb-matchmaker/series_season.go b/cmd/acb-matchmaker/series_season.go index 166240f..8d9eef9 100644 --- a/cmd/acb-matchmaker/series_season.go +++ b/cmd/acb-matchmaker/series_season.go @@ -277,15 +277,17 @@ func (m *Matchmaker) scheduleNextSeriesGames(ctx context.Context) error { continue } - // Check that both bots are active + // Check that both bots are active and not on crash cooldown (§4.5, §6.1) var aActive, bActive bool err := m.db.QueryRowContext(ctx, - `SELECT EXISTS(SELECT 1 FROM bots WHERE bot_id = $1 AND status = 'active')`, s.BotAID).Scan(&aActive) + `SELECT EXISTS(SELECT 1 FROM bots WHERE bot_id = $1 AND status = 'active' + AND (cooldown_until IS NULL OR cooldown_until < NOW()))`, s.BotAID).Scan(&aActive) if err != nil { continue } err = m.db.QueryRowContext(ctx, - `SELECT EXISTS(SELECT 1 FROM bots WHERE bot_id = $1 AND status = 'active')`, s.BotBID).Scan(&bActive) + `SELECT EXISTS(SELECT 1 FROM bots WHERE bot_id = $1 AND status = 'active' + AND (cooldown_until IS NULL OR cooldown_until < NOW()))`, s.BotBID).Scan(&bActive) if err != nil { continue } @@ -496,10 +498,11 @@ func (m *Matchmaker) selectSeriesMap(ctx context.Context, gameNum int, rng *rand // autoCreateSeries creates best-of-5 series between top-20 active bots, // one per bot per day. func (m *Matchmaker) autoCreateSeries(ctx context.Context) error { - // Find top-20 active bots by rating + // Find top-20 active bots by rating (excluding crash-cooldown bots) rows, err := m.db.QueryContext(ctx, ` SELECT bot_id FROM bots WHERE status = 'active' AND evolved = false + AND (cooldown_until IS NULL OR cooldown_until < NOW()) ORDER BY rating_mu DESC LIMIT 20 `) @@ -542,6 +545,7 @@ func (m *Matchmaker) autoCreateSeries(ctx context.Context) error { SELECT b.bot_id FROM bots b WHERE b.bot_id != $1 AND b.status = 'active' + AND (b.cooldown_until IS NULL OR b.cooldown_until < NOW()) AND NOT EXISTS ( SELECT 1 FROM series s WHERE ((s.bot_a_id = $1 AND s.bot_b_id = b.bot_id) @@ -913,10 +917,11 @@ func (m *Matchmaker) createChampionshipBracket(ctx context.Context, seasonID int return nil // already created } - // Get top 8 active bots by rating + // Get top 8 active bots by rating (excluding crash-cooldown bots) rows, err := m.db.QueryContext(ctx, ` SELECT bot_id FROM bots WHERE status = 'active' + AND (cooldown_until IS NULL OR cooldown_until < NOW()) ORDER BY rating_mu DESC LIMIT 8 `) diff --git a/cmd/acb-matchmaker/tickers.go b/cmd/acb-matchmaker/tickers.go index c595632..951e923 100644 --- a/cmd/acb-matchmaker/tickers.go +++ b/cmd/acb-matchmaker/tickers.go @@ -38,10 +38,12 @@ func (m *Matchmaker) runTicker(ctx context.Context, name string, interval time.D // tickMatchmaker creates matches between active bots and enqueues jobs. func (m *Matchmaker) tickMatchmaker(ctx context.Context) { - // Get all active bots + // Get all active bots not on crash cooldown (§4.5, §6.1) rows, err := m.db.QueryContext(ctx, `SELECT bot_id, endpoint_url, shared_secret, rating_mu, rating_phi - FROM bots WHERE status = 'active' ORDER BY rating_mu DESC`) + FROM bots WHERE status = 'active' + AND (cooldown_until IS NULL OR cooldown_until < NOW()) + ORDER BY rating_mu DESC`) if err != nil { log.Printf("matchmaker: query error: %v", err) return diff --git a/cmd/acb-worker/api.go b/cmd/acb-worker/api.go index b23411c..71fb7bc 100644 --- a/cmd/acb-worker/api.go +++ b/cmd/acb-worker/api.go @@ -78,10 +78,11 @@ type BotSecret struct { // MatchResult represents the result of a match for submission. type MatchResult struct { - WinnerID string `json:"winner_id"` - Turns int `json:"turns"` - EndReason string `json:"end_reason"` - Scores map[string]int `json:"scores"` + WinnerID string `json:"winner_id"` + Turns int `json:"turns"` + EndReason string `json:"end_reason"` + Scores map[string]int `json:"scores"` + CrashedBots map[string]bool `json:"crashed_bots"` // bot_id -> crashed } // ConvertDBJobToJob converts a DBJob to Job type. diff --git a/cmd/acb-worker/db.go b/cmd/acb-worker/db.go index 2136660..05def02 100644 --- a/cmd/acb-worker/db.go +++ b/cmd/acb-worker/db.go @@ -393,6 +393,11 @@ func (c *DBClient) SubmitMatchResult(ctx context.Context, jobID string, result * log.Printf("failed to update series result for match %s: %v", matchID, err) } + // Update crash strikes and cooldown for each participant + if err := updateCrashStrikes(ctx, tx, result.CrashedBots); err != nil { + log.Printf("failed to update crash strikes for match %s: %v", matchID, err) + } + if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } @@ -498,6 +503,49 @@ func (c *DBClient) FailJob(ctx context.Context, jobID string, workerID string, e return nil } +// CrashCooldownDuration is the 30-minute cooldown when 3 consecutive crashes are detected. +const CrashCooldownDuration = 30 * time.Minute + +// MaxCrashStrikes is the number of consecutive crashes that triggers cooldown. +const MaxCrashStrikes = 3 + +// updateCrashStrikes updates the crash_strikes and cooldown_until columns for +// match participants. A crashed bot gets its strikes incremented; a non-crashed +// bot has its strikes reset to 0. When strikes reach MaxCrashStrikes, cooldown +// is set to now + 30 min. +func updateCrashStrikes(ctx context.Context, tx *sql.Tx, crashedBots map[string]bool) error { + if len(crashedBots) == 0 { + return nil + } + + for botID, crashed := range crashedBots { + if crashed { + // Increment strikes; if threshold reached, set cooldown + _, err := tx.ExecContext(ctx, ` + UPDATE bots + SET crash_strikes = crash_strikes + 1, + cooldown_until = CASE + WHEN crash_strikes + 1 >= $1 THEN NOW() + $2 + ELSE cooldown_until + END + WHERE bot_id = $3 + `, MaxCrashStrikes, CrashCooldownDuration, botID) + if err != nil { + return fmt.Errorf("failed to increment crash strikes for %s: %w", botID, err) + } + } else { + // Reset strikes on successful match + _, err := tx.ExecContext(ctx, ` + UPDATE bots SET crash_strikes = 0 WHERE bot_id = $1 + `, botID) + if err != nil { + return fmt.Errorf("failed to reset crash strikes for %s: %w", botID, err) + } + } + } + return nil +} + // RatingUpdate represents a Glicko-2 rating update for a bot. type RatingUpdate struct { BotID string diff --git a/engine/game.go b/engine/game.go index fcb5472..6939b25 100644 --- a/engine/game.go +++ b/engine/game.go @@ -15,9 +15,10 @@ type GameState struct { Energy []*EnergyNode Players []*Player Turn int - MatchID string - NextBotID int - rng *rand.Rand + MatchID string + NextBotID int + NextCoreID int + rng *rand.Rand // Turn state Moves map[int]Move // bot ID -> move @@ -96,7 +97,9 @@ func (gs *GameState) AddCore(owner int, pos Position) *Core { Position: gs.Grid.WrapPos(pos), Owner: owner, Active: true, + ID: gs.NextCoreID, } + gs.NextCoreID++ gs.Cores = append(gs.Cores, c) gs.Grid.SetPos(c.Position, TileCore) @@ -319,20 +322,21 @@ func (gs *GameState) ToJSON() ([]byte, error) { // Clone creates a deep copy of the game state. func (gs *GameState) Clone() *GameState { newGS := &GameState{ - Config: gs.Config, - Grid: NewGrid(gs.Config.Rows, gs.Config.Cols), - Bots: make([]*Bot, len(gs.Bots)), - Cores: make([]*Core, len(gs.Cores)), - Energy: make([]*EnergyNode, len(gs.Energy)), - Players: make([]*Player, len(gs.Players)), - Turn: gs.Turn, - MatchID: gs.MatchID, - NextBotID: gs.NextBotID, - rng: gs.rng, - Moves: make(map[int]Move), - DeadBots: make([]*Bot, 0), - Events: make([]Event, 0), - Dominance: make(map[int]int), + Config: gs.Config, + Grid: NewGrid(gs.Config.Rows, gs.Config.Cols), + Bots: make([]*Bot, len(gs.Bots)), + Cores: make([]*Core, len(gs.Cores)), + Energy: make([]*EnergyNode, len(gs.Energy)), + Players: make([]*Player, len(gs.Players)), + Turn: gs.Turn, + MatchID: gs.MatchID, + NextBotID: gs.NextBotID, + NextCoreID: gs.NextCoreID, + rng: gs.rng, + Moves: make(map[int]Move), + DeadBots: make([]*Bot, 0), + Events: make([]Event, 0), + Dominance: make(map[int]int), } // Copy grid @@ -358,9 +362,11 @@ func (gs *GameState) Clone() *GameState { // Copy cores for i, c := range gs.Cores { newGS.Cores[i] = &Core{ - Position: c.Position, - Owner: c.Owner, - Active: c.Active, + Position: c.Position, + Owner: c.Owner, + Active: c.Active, + ID: c.ID, + LastSpawnedTurn: c.LastSpawnedTurn, } } diff --git a/engine/match.go b/engine/match.go index 8b8ab5f..3d39243 100644 --- a/engine/match.go +++ b/engine/match.go @@ -171,6 +171,14 @@ func (mr *MatchRunner) Run() (*MatchResult, *Replay, error) { winProbs, criticalMoments := ComputeWinProbability(snapshots, 100, mr.rng) replayWriter.SetWinProbability(winProbs, criticalMoments) + // Populate crash status per player + result.Crashed = make([]bool, len(mr.bots)) + for i, bot := range mr.bots { + if hb, ok := bot.(*HTTPBot); ok { + result.Crashed[i] = hb.IsCrashed() + } + } + // Finalize replay replayWriter.Finalize(result) diff --git a/engine/turn.go b/engine/turn.go index 2a7f989..5f9ee6c 100644 --- a/engine/turn.go +++ b/engine/turn.go @@ -1,5 +1,7 @@ package engine +import "sort" + // TurnPhase represents a phase of turn execution. type TurnPhase int @@ -289,6 +291,8 @@ func (gs *GameState) executeCollection() { } // executeSpawns handles bot spawning at active cores. +// When multiple cores are eligible, the core idle longest spawns first +// (deterministic tiebreak: lowest core ID wins). func (gs *GameState) executeSpawns() { // For each player, check if they can spawn for _, p := range gs.Players { @@ -296,13 +300,12 @@ func (gs *GameState) executeSpawns() { continue } - // Find active cores owned by this player that are unoccupied + // Collect eligible cores: active, owned by this player, unoccupied + var eligible []*Core for _, c := range gs.Cores { if !c.Active || c.Owner != p.ID { continue } - - // Check if core is occupied occupied := false for _, b := range gs.Bots { if b.Alive && b.Position == c.Position { @@ -310,13 +313,22 @@ func (gs *GameState) executeSpawns() { break } } - - if !occupied && p.Energy >= gs.Config.SpawnCost { - // Spawn a bot - gs.SpawnBot(p.ID, c.Position) - p.Energy -= gs.Config.SpawnCost + if !occupied { + eligible = append(eligible, c) } } + + // Sort by (lastSpawnedTurn ASC, core ID ASC) — idle-longest first + sortCoresByPriority(eligible) + + for _, c := range eligible { + if p.Energy < gs.Config.SpawnCost { + break + } + gs.SpawnBot(p.ID, c.Position) + c.LastSpawnedTurn = gs.Turn + p.Energy -= gs.Config.SpawnCost + } } } @@ -450,3 +462,15 @@ func (gs *GameState) findWinnerByScore() int { return bestPlayer } + +// sortCoresByPriority sorts cores by (LastSpawnedTurn ASC, ID ASC). +// The core idle longest (lowest LastSpawnedTurn) spawns first; +// equal idle time is broken by lower core ID. +func sortCoresByPriority(cores []*Core) { + sort.Slice(cores, func(i, j int) bool { + if cores[i].LastSpawnedTurn != cores[j].LastSpawnedTurn { + return cores[i].LastSpawnedTurn < cores[j].LastSpawnedTurn + } + return cores[i].ID < cores[j].ID + }) +} diff --git a/engine/types.go b/engine/types.go index 2ec2d82..769645a 100644 --- a/engine/types.go +++ b/engine/types.go @@ -104,9 +104,11 @@ type Bot struct { // Core represents a spawn point owned by a player. type Core struct { - Position Position `json:"position"` - Owner int `json:"owner"` - Active bool `json:"active"` // false if razed + Position Position `json:"position"` + Owner int `json:"owner"` + Active bool `json:"active"` // false if razed + ID int `json:"id"` // unique core identifier + LastSpawnedTurn int `json:"last_spawned_turn"` // turn when this core last spawned a bot } // EnergyNode represents an energy spawn location. @@ -193,12 +195,13 @@ func ConfigForPlayers(numPlayers, coresPerPlayer int) Config { // MatchResult represents the outcome of a match. type MatchResult struct { - Winner int `json:"winner"` // -1 for draw - Reason string `json:"reason"` // "elimination", "dominance", "turns", "draw" - Turns int `json:"turns"` - Scores []int `json:"scores"` - Energy []int `json:"energy"` // energy collected per player - BotsAlive []int `json:"bots_alive"` + Winner int `json:"winner"` // -1 for draw + Reason string `json:"reason"` // "elimination", "dominance", "turns", "draw" + Turns int `json:"turns"` + Scores []int `json:"scores"` + Energy []int `json:"energy"` // energy collected per player + BotsAlive []int `json:"bots_alive"` + Crashed []bool `json:"crashed"` // per-player: true if bot was marked crashed during match } // BotInterface defines the interface for bot decision-making. diff --git a/web/src/app.ts b/web/src/app.ts index 28285d9..f928676 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -187,6 +187,15 @@ router.beforeNavigate((from: string, _to: string) => { if (from && from !== '/') { savePageCache(from); } + + // Cleanup VirtualList instances to prevent leaked ResizeObservers + const app = document.getElementById('app'); + if (app) { + app.querySelectorAll('[data-virtual-list]').forEach(el => { + const vl = (el as any)._virtualList; + if (vl && typeof vl.destroy === 'function') vl.destroy(); + }); + } }); // ─── Route definitions ───────────────────────────────────────────────────────────── diff --git a/web/src/components/playlist-carousel.ts b/web/src/components/playlist-carousel.ts index 5a7bae7..158c3d6 100644 --- a/web/src/components/playlist-carousel.ts +++ b/web/src/components/playlist-carousel.ts @@ -15,6 +15,10 @@ import { const loadReplayViewer = () => import('../replay-viewer'); +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + // ── Touch tracking for live 60fps swipe ───────────────────────────────────── interface TouchTracker { @@ -306,7 +310,7 @@ export class PlaylistCarousel { // Update header this.headerBar.innerHTML = ` - ${this.playlist.title} + ${escapeHtml(this.playlist.title)} ${index + 1} of ${this.playlist.matches.length} `; @@ -397,7 +401,7 @@ export class PlaylistCarousel { const players = replay.players.map((p, i) => { const score = replay.result.scores?.[i] ?? '-'; const won = replay.result.winner === i; - return `${p.name} ${score}`; + return `${escapeHtml(p.name)} ${score}`; }).join(' vs '); this.scoreBar.innerHTML = players; } @@ -412,12 +416,12 @@ export class PlaylistCarousel { private updateMetadataContent(match: PlaylistMatch, replay: Replay | null): void { const parts: string[] = []; - parts.push(``); - if (match.curation_tag) parts.push(``); + parts.push(``); + if (match.curation_tag) parts.push(``); if (replay) { parts.push(``); parts.push(``); - if (replay.result.reason) parts.push(``); + if (replay.result.reason) parts.push(``); } if (match.completed_at) { const d = new Date(match.completed_at); diff --git a/web/src/lib/virtual-list.ts b/web/src/lib/virtual-list.ts index 771e1bc..72a668f 100644 --- a/web/src/lib/virtual-list.ts +++ b/web/src/lib/virtual-list.ts @@ -56,6 +56,7 @@ export class VirtualList { mount(container: HTMLElement): void { container.innerHTML = ''; container.classList.add(this.opts.containerClass ?? 'virtual-list'); + container.setAttribute('data-virtual-list', ''); // Scrollable viewport const scrollEl = document.createElement('div'); diff --git a/web/src/pages/bot-profile.ts b/web/src/pages/bot-profile.ts index b492324..81e75b7 100644 --- a/web/src/pages/bot-profile.ts +++ b/web/src/pages/bot-profile.ts @@ -164,9 +164,11 @@ function renderRecentMatches(matches: BotProfile['recent_matches']): string { if (rest.length === 0) return html; + // Render remaining matches but wrap them in a collapsed container + const restHtml = rest.map(match => renderMatchItem(match)).join(''); return ` ${html} -
+