diff --git a/cmd/acb-api/db.go b/cmd/acb-api/db.go index 736f6e2..5d5ed30 100644 --- a/cmd/acb-api/db.go +++ b/cmd/acb-api/db.go @@ -262,6 +262,13 @@ CREATE TABLE IF NOT EXISTS playlist_matches ( ); CREATE INDEX IF NOT EXISTS idx_playlist_matches_playlist ON playlist_matches(playlist_slug, sort_order); +-- Add combat_turns column to matches if it doesn't exist (idempotent migration) +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'matches' AND column_name = 'combat_turns') THEN + ALTER TABLE matches ADD COLUMN combat_turns INTEGER NOT NULL DEFAULT 0; + END IF; +END $$; + -- Community replay feedback (plan §13.6, §8.3) CREATE TABLE IF NOT EXISTS replay_feedback ( feedback_id VARCHAR(32) PRIMARY KEY, diff --git a/cmd/acb-index-builder/db.go b/cmd/acb-index-builder/db.go index 75d5f4d..d49d761 100644 --- a/cmd/acb-index-builder/db.go +++ b/cmd/acb-index-builder/db.go @@ -38,6 +38,7 @@ type MatchData struct { WinnerID string `json:"winner_id,omitempty"` TurnCount int `json:"turn_count"` EndCondition string `json:"end_condition"` + CombatTurns int `json:"combat_turns"` // turns with ≥1 enemy-kill combat death Participants []ParticipantData `json:"participants"` CreatedAt time.Time `json:"created_at"` CompletedAt time.Time `json:"completed_at"` @@ -341,6 +342,7 @@ func getBotMatchStats(ctx context.Context, db *sql.DB, botID string) (played, wo func fetchMatches(ctx context.Context, db *sql.DB) ([]MatchData, error) { query := ` SELECT m.match_id, m.map_id, m.winner, m.turn_count, m.condition, + COALESCE(m.combat_turns, 0), m.created_at, m.completed_at, COALESCE( json_agg( @@ -364,7 +366,7 @@ func fetchMatches(ctx context.Context, db *sql.DB) ([]MatchData, error) { LEFT JOIN match_participants mp ON m.match_id = mp.match_id WHERE m.status = 'completed' GROUP BY m.match_id, m.map_id, m.winner, m.turn_count, m.condition, - m.created_at, m.completed_at + m.combat_turns, m.created_at, m.completed_at ORDER BY m.completed_at DESC LIMIT 1000 ` @@ -383,6 +385,7 @@ func fetchMatches(ctx context.Context, db *sql.DB) ([]MatchData, error) { err := rows.Scan( &m.ID, &m.MapID, &winnerID, &m.TurnCount, &m.EndCondition, + &m.CombatTurns, &m.CreatedAt, &m.CompletedAt, &participantsJSON, ) if err != nil { diff --git a/cmd/acb-index-builder/generator.go b/cmd/acb-index-builder/generator.go index 04ec58b..9ec9cd8 100644 --- a/cmd/acb-index-builder/generator.go +++ b/cmd/acb-index-builder/generator.go @@ -81,6 +81,7 @@ type MatchSummary struct { Turns int `json:"turns"` EndReason string `json:"end_reason"` Enriched bool `json:"enriched"` + CombatTurns int `json:"combat_turns"` // turns with ≥1 enemy-kill combat death } // MatchParticipantSummary represents a bot in a match summary @@ -313,6 +314,12 @@ func generateMatchIndex(data *IndexData, outputDir string, botNameMap map[string summaries = append(summaries, matchToSummary(m, data, cfg)) } + // Sort matches by combat_turns descending so the most combat-heavy + // matches surface first in the UI. + sort.Slice(summaries, func(i, j int) bool { + return summaries[i].CombatTurns > summaries[j].CombatTurns + }) + index := MatchIndex{ UpdatedAt: data.GeneratedAt.Format(time.RFC3339), Matches: summaries, @@ -350,6 +357,7 @@ func matchToSummary(m MatchData, data *IndexData, cfg *Config) MatchSummary { Turns: m.TurnCount, EndReason: m.EndCondition, Enriched: enriched, + CombatTurns: m.CombatTurns, } } @@ -642,6 +650,18 @@ func generatePlaylists(data *IndexData, outputDir string, botNameMap map[string] sortByInterestScore(matches) }, }, + { + slug: "most-combat", + title: "Most Combat", + description: "Matches with the most turns featuring enemy kills — the bloodiest battles on the grid", + category: "featured", + filter: func(m MatchData) bool { + return m.WinnerID != "" && m.CombatTurns > 0 + }, + sort: func(matches []MatchData) { + sortByCombatTurns(matches) + }, + }, { slug: "featured", title: "Featured Matches", @@ -1046,6 +1066,12 @@ func interestScore(m MatchData) float64 { } else if cr >= 3200 { score += 1.0 } + // Combat-heavy matches are exciting + if m.CombatTurns >= 30 { + score += 2.0 + } else if m.CombatTurns >= 15 { + score += 1.0 + } return score } @@ -1073,6 +1099,12 @@ func sortByCombinedRating(matches []MatchData) { }) } +func sortByCombatTurns(matches []MatchData) { + sortSlice(matches, func(i, j int) bool { + return matches[i].CombatTurns > matches[j].CombatTurns + }) +} + func sortByInterestScore(matches []MatchData) { sortSlice(matches, func(i, j int) bool { return interestScore(matches[i]) > interestScore(matches[j]) diff --git a/cmd/acb-worker/api.go b/cmd/acb-worker/api.go index 71fb7bc..cc631bc 100644 --- a/cmd/acb-worker/api.go +++ b/cmd/acb-worker/api.go @@ -83,6 +83,7 @@ type MatchResult struct { EndReason string `json:"end_reason"` Scores map[string]int `json:"scores"` CrashedBots map[string]bool `json:"crashed_bots"` // bot_id -> crashed + CombatTurns int `json:"combat_turns"` // turns with ≥1 enemy-kill combat death } // ConvertDBJobToJob converts a DBJob to Job type. diff --git a/cmd/acb-worker/db.go b/cmd/acb-worker/db.go index b4ba730..672ac83 100644 --- a/cmd/acb-worker/db.go +++ b/cmd/acb-worker/db.go @@ -336,9 +336,10 @@ func (c *DBClient) SubmitMatchResult(ctx context.Context, jobID string, result * _, err = tx.ExecContext(ctx, ` UPDATE matches SET status = 'completed', winner = $1, condition = $2, - turn_count = $3, scores_json = $4, completed_at = $5 + turn_count = $3, scores_json = $4, completed_at = $5, + combat_turns = $7 WHERE match_id = $6 - `, winnerIndex, result.EndReason, result.Turns, scoresJSON, now, matchID) + `, winnerIndex, result.EndReason, result.Turns, scoresJSON, now, matchID, result.CombatTurns) if err != nil { return fmt.Errorf("failed to update match: %w", err) } diff --git a/cmd/acb-worker/main.go b/cmd/acb-worker/main.go index 6b7ca88..2db7acc 100644 --- a/cmd/acb-worker/main.go +++ b/cmd/acb-worker/main.go @@ -389,9 +389,39 @@ func (w *Worker) executeMatch(ctx context.Context, claimData *JobClaimData) (*Ma } } + // Compute combat_turns: count distinct turns where ≥1 bot died from "combat" (enemy kill) + result.CombatTurns = computeCombatTurns(replay) + return result, replay, nil } +// computeCombatTurns counts the number of distinct turns in a replay where at +// least one bot was killed by an enemy (reason == "combat"). Deaths from +// self-collision or other causes are excluded. +func computeCombatTurns(replay *engine.Replay) int { + if replay == nil { + return 0 + } + combatTurnSet := make(map[int]struct{}) + for _, turn := range replay.Turns { + for _, event := range turn.Events { + if event.Type != engine.EventBotDied { + continue + } + details, ok := event.Details.(map[string]interface{}) + if !ok { + continue + } + reason, _ := details["reason"].(string) + if reason == "combat" { + combatTurnSet[turn.Turn] = struct{}{} + break // one combat death is enough to count this turn + } + } + } + return len(combatTurnSet) +} + // sendHeartbeats sends periodic heartbeats while a match is running. func (w *Worker) sendHeartbeats(ctx context.Context, jobID string) { ticker := time.NewTicker(w.heartbeat) diff --git a/web/src/pages/home.ts b/web/src/pages/home.ts index 5745e54..7acc968 100644 --- a/web/src/pages/home.ts +++ b/web/src/pages/home.ts @@ -145,8 +145,8 @@ export async function renderHomePage(): Promise { // Featured replay: use demo replay as fallback when no live matches const hasLiveReplay = !!featuredReplay; const replayEmbedSrc = hasLiveReplay - ? `/embed.html?match_id=${featuredReplay!.id}&autoplay=true&speed=150&loop=true&view=influence` - : '/embed.html?demo=true&autoplay=true&speed=150&loop=true&view=influence'; + ? `/embed.html?match_id=${featuredReplay!.id}&autoplay=true&speed=150&loop=true&view=standard` + : '/embed.html?demo=true&autoplay=true&speed=150&loop=true&view=standard'; const replayTitle = hasLiveReplay ? `${featuredReplay!.participants.map((p) => `${esc(p.name)}`).join(' vs ')}${featuredReplay!.winner_id ? ` — Winner: ${esc(featuredReplay!.participants.find((p) => p.bot_id === featuredReplay!.winner_id)?.name || 'Unknown')}` : ''}` : 'Demo Replay — Watch a sample battle';