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:
parent
54548e4873
commit
4937f94afd
7 changed files with 79 additions and 5 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue