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} -
+