feat(combat): rank matches by enemy-kill combat turns

Adds combat_turns metric (distinct turns where ≥1 bot died from enemy
focus-fire, excluding self-collisions). Worker computes it after each
match; index builder sorts matches/index.json and the new most-combat
playlist descending by it, and bumps interest score for combat-heavy
matches so they surface in highlights.

Also switches homepage featured replay default view from influence to
standard so the actual bot-on-bot combat is visible.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-03 18:32:08 -04:00
parent 54548e4873
commit 4937f94afd
7 changed files with 79 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -145,8 +145,8 @@ export async function renderHomePage(): Promise<void> {
// 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) => `<strong>${esc(p.name)}</strong>`).join(' vs ')}${featuredReplay!.winner_id ? ` — Winner: <strong>${esc(featuredReplay!.participants.find((p) => p.bot_id === featuredReplay!.winner_id)?.name || 'Unknown')}</strong>` : ''}`
: 'Demo Replay — Watch a sample battle';