fix(matchmaker): multi-match crash cooldown (3 strikes / 30 min) per §4.5 + §6.1

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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 15:22:12 -04:00
parent da824f7360
commit c56cc8bae6
18 changed files with 196 additions and 64 deletions

View file

@ -1 +1 @@
30f2c63b20a8363b6d9b21829b8c7375dd127d8b
da824f736002b1e597d4c5c658c1122a1f3895b4

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<HTMLElement>('[data-virtual-list]').forEach(el => {
const vl = (el as any)._virtualList;
if (vl && typeof vl.destroy === 'function') vl.destroy();
});
}
});
// ─── Route definitions ─────────────────────────────────────────────────────────────

View file

@ -15,6 +15,10 @@ import {
const loadReplayViewer = () => import('../replay-viewer');
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Touch tracking for live 60fps swipe ─────────────────────────────────────
interface TouchTracker {
@ -306,7 +310,7 @@ export class PlaylistCarousel {
// Update header
this.headerBar.innerHTML = `
<span class="carousel-playlist-name">${this.playlist.title}</span>
<span class="carousel-playlist-name">${escapeHtml(this.playlist.title)}</span>
<span class="carousel-counter">${index + 1} of ${this.playlist.matches.length}</span>
`;
@ -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 `<span class="carousel-player${won ? ' carousel-winner' : ''}">${p.name} ${score}</span>`;
return `<span class="carousel-player${won ? ' carousel-winner' : ''}">${escapeHtml(p.name)} ${score}</span>`;
}).join(' <span class="carousel-vs">vs</span> ');
this.scoreBar.innerHTML = players;
}
@ -412,12 +416,12 @@ export class PlaylistCarousel {
private updateMetadataContent(match: PlaylistMatch, replay: Replay | null): void {
const parts: string[] = [];
parts.push(`<div class="carousel-meta-title">${match.title ?? `Match ${match.order + 1}`}</div>`);
if (match.curation_tag) parts.push(`<div class="carousel-meta-tag">${match.curation_tag}</div>`);
parts.push(`<div class="carousel-meta-title">${escapeHtml(match.title ?? `Match ${match.order + 1}`)}</div>`);
if (match.curation_tag) parts.push(`<div class="carousel-meta-tag">${escapeHtml(match.curation_tag)}</div>`);
if (replay) {
parts.push(`<div class="carousel-meta-row"><span>Turns</span><span>${replay.turns.length}</span></div>`);
parts.push(`<div class="carousel-meta-row"><span>Map</span><span>${replay.map.rows}x${replay.map.cols}</span></div>`);
if (replay.result.reason) parts.push(`<div class="carousel-meta-row"><span>End</span><span>${replay.result.reason}</span></div>`);
if (replay.result.reason) parts.push(`<div class="carousel-meta-row"><span>End</span><span>${escapeHtml(replay.result.reason)}</span></div>`);
}
if (match.completed_at) {
const d = new Date(match.completed_at);

View file

@ -56,6 +56,7 @@ export class VirtualList<T> {
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');

View file

@ -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}
<div class="match-list-rest" data-rest-count="${rest.length}"></div>
<div class="match-list-rest" style="display:none">${restHtml}</div>
<button class="btn small show-more-matches" type="button"
aria-label="Show ${rest.length} more matches">
Show ${rest.length} more matches
@ -231,9 +233,9 @@ function wireShowMoreMatches(contentEl: HTMLElement): void {
btn.dataset.wired = '1';
btn.addEventListener('click', () => {
// In a real implementation, we'd fetch more from the data.
// For now, just expand all from the profile data.
restEl.remove();
// Move hidden match items into the visible list
restEl.style.display = '';
restEl.classList.add('expanded');
btn.remove();
});
}

View file

@ -233,6 +233,11 @@ function renderMatchesList(
}
}, { rootMargin: '300px' });
observer.observe(remainingEl);
// Cleanup on page navigation
const cleanup = () => observer.disconnect();
container.addEventListener('pageunload', cleanup);
window.addEventListener('hashchange', cleanup, { once: true });
}
}
}

View file

@ -381,7 +381,7 @@ async function showPlaylistDetail(slug: string): Promise<void> {
const playlist: Playlist = await fetchPlaylist(slug);
// Mobile: defer to carousel component if available
if (isMobile() && playlist.matches.length > 3) {
if (isMobile() && playlist.matches.length > 0) {
try {
const { PlaylistCarousel } = await import('../components/playlist-carousel');
new PlaylistCarousel({