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:
parent
da824f7360
commit
c56cc8bae6
18 changed files with 196 additions and 64 deletions
|
|
@ -1 +1 @@
|
|||
30f2c63b20a8363b6d9b21829b8c7375dd127d8b
|
||||
da824f736002b1e597d4c5c658c1122a1f3895b4
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import {
|
|||
|
||||
const loadReplayViewer = () => import('../replay-viewer');
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').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 = `
|
||||
<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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue