ai-code-battle/cmd/acb-matchmaker/map_fairness.go
jedarden ea04f4debb style: apply gofmt alignment fixes across codebase
Tab/space alignment consistency from running gofmt on all packages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:40:33 -04:00

277 lines
7.8 KiB
Go

package main
import (
"context"
"fmt"
"log"
"math"
"time"
)
const (
fairnessMinGames = 80
fairnessThresholdPP = 0.10
voteForceRetireThreshold = -20
engagementPrunePct = 0.10
classicMinMonths = 3
classicTopN = 5
)
// tickFairnessAudit runs the full map lifecycle audit:
// 1. Update map_fairness from completed matches
// 2. Flag positionally unfair maps as probation
// 3. Force-retire maps with >20 net negative votes
// 4. Monthly: prune bottom 10% by engagement
// 5. Promote top-5 sustained maps to classic
func (m *Matchmaker) tickFairnessAudit(ctx context.Context) {
if err := m.updateMapFairnessStats(ctx); err != nil {
log.Printf("fairness-audit: update stats error: %v", err)
}
if err := m.flagUnfairMaps(ctx); err != nil {
log.Printf("fairness-audit: flag unfair error: %v", err)
}
if err := m.retireDislikedMaps(ctx); err != nil {
log.Printf("fairness-audit: retire disliked error: %v", err)
}
if err := m.pruneLowEngagementMaps(ctx); err != nil {
log.Printf("fairness-audit: prune engagement error: %v", err)
}
if err := m.promoteClassicMaps(ctx); err != nil {
log.Printf("fairness-audit: promote classic error: %v", err)
}
}
// updateMapFairnessStats recomputes per-slot win counts from match_participants
// and writes them into the map_fairness table for all active/probation maps.
func (m *Matchmaker) updateMapFairnessStats(ctx context.Context) error {
// For each map+slot, count completed matches where that slot won.
rows, err := m.db.QueryContext(ctx, `
SELECT m.map_id, mp.player_slot,
COUNT(*) AS games,
COUNT(*) FILTER (WHERE m.winner = mp.player_slot) AS wins
FROM match_participants mp
JOIN matches m ON m.match_id = mp.match_id
JOIN maps map ON map.map_id = m.map_id
WHERE m.status = 'completed'
AND map.status IN ('active', 'probation')
GROUP BY m.map_id, mp.player_slot
`)
if err != nil {
return fmt.Errorf("query fairness stats: %w", err)
}
defer rows.Close()
type fairnessRow struct {
MapID string
PlayerSlot int
Games int
Wins int
}
var stats []fairnessRow
for rows.Next() {
var r fairnessRow
if err := rows.Scan(&r.MapID, &r.PlayerSlot, &r.Games, &r.Wins); err != nil {
return fmt.Errorf("scan fairness row: %w", err)
}
stats = append(stats, r)
}
if err := rows.Err(); err != nil {
return err
}
for _, s := range stats {
_, err := m.db.ExecContext(ctx, `
INSERT INTO map_fairness (map_id, player_slot, games, wins, last_check)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (map_id, player_slot) DO UPDATE
SET games = $3, wins = $4, last_check = NOW()
`, s.MapID, s.PlayerSlot, s.Games, s.Wins)
if err != nil {
log.Printf("fairness-audit: update map_fairness for %s slot %d: %v", s.MapID, s.PlayerSlot, err)
}
}
if len(stats) > 0 {
log.Printf("fairness-audit: updated fairness stats for %d map-slot pairs", len(stats))
}
return nil
}
// flagUnfairMaps sets status='probation' for maps where any player slot's
// win rate deviates from expected (1/N for N-player) by more than 10pp
// across 80+ completed matches.
func (m *Matchmaker) flagUnfairMaps(ctx context.Context) error {
// Find maps where any slot has >=80 games and win rate deviation > 10pp.
rows, err := m.db.QueryContext(ctx, `
WITH slot_rates AS (
SELECT
mf.map_id,
mf.player_slot,
mf.games,
mf.wins,
mf.wins::float / NULLIF(mf.games, 0) AS win_rate,
map.player_count,
1.0 / map.player_count AS expected_rate
FROM map_fairness mf
JOIN maps map ON map.map_id = mf.map_id
WHERE map.status = 'active'
AND mf.games >= $1
),
unfair AS (
SELECT DISTINCT map_id
FROM slot_rates
WHERE ABS(win_rate - expected_rate) > $2
)
SELECT map_id FROM unfair
`, fairnessMinGames, fairnessThresholdPP)
if err != nil {
return fmt.Errorf("query unfair maps: %w", err)
}
defer rows.Close()
var flagged []string
for rows.Next() {
var mapID string
if err := rows.Scan(&mapID); err != nil {
return err
}
flagged = append(flagged, mapID)
}
for _, mapID := range flagged {
_, err := m.db.ExecContext(ctx, `
UPDATE maps SET status = 'probation' WHERE map_id = $1 AND status = 'active'
`, mapID)
if err != nil {
log.Printf("fairness-audit: failed to flag %s as probation: %v", mapID, err)
continue
}
log.Printf("fairness-audit: flagged map %s as probation (positional unfairness detected)", mapID)
}
return nil
}
// retireDislikedMaps force-retires maps with >20 net negative votes,
// regardless of engagement score.
func (m *Matchmaker) retireDislikedMaps(ctx context.Context) error {
result, err := m.db.ExecContext(ctx, `
UPDATE maps m SET
status = 'retired',
retired_at = NOW()
FROM (
SELECT map_id, SUM(vote)::int AS net_votes
FROM map_votes
GROUP BY map_id
HAVING SUM(vote) < $1
) v
WHERE m.map_id = v.map_id
AND m.status IN ('active', 'probation')
`, voteForceRetireThreshold)
if err != nil {
return fmt.Errorf("retire disliked maps: %w", err)
}
affected, _ := result.RowsAffected()
if affected > 0 {
log.Printf("fairness-audit: force-retired %d map(s) with <%d net votes", affected, voteForceRetireThreshold)
}
return nil
}
// pruneLowEngagementMaps retires the bottom 10% of active maps by rolling
// average engagement score, run once per month (checked by day-of-month).
func (m *Matchmaker) pruneLowEngagementMaps(ctx context.Context) error {
// Only run on the 1st of each month.
if time.Now().Day() != 1 {
return nil
}
// Check if we already pruned this month.
var prunedThisMonth int
err := m.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM maps
WHERE retired_at >= DATE_TRUNC('month', CURRENT_DATE)
AND retired_at IS NOT NULL
`).Scan(&prunedThisMonth)
if err != nil {
return fmt.Errorf("check monthly prune: %w", err)
}
if prunedThisMonth > 0 {
return nil
}
// Compute engagement from map_scores rolling average, falling back to maps.engagement.
// Count active maps per player_count, then prune bottom 10% within each tier.
for _, pc := range []int{2, 3, 4, 6} {
var totalActive int
err := m.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM maps
WHERE player_count = $1 AND status = 'active'
`, pc).Scan(&totalActive)
if err != nil || totalActive < 10 {
continue
}
toPrune := int(math.Ceil(float64(totalActive) * engagementPrunePct))
if toPrune < 1 {
continue
}
result, err := m.db.ExecContext(ctx, `
UPDATE maps m SET
status = 'retired',
retired_at = NOW()
FROM (
SELECT map_id FROM maps
WHERE player_count = $1 AND status = 'active'
ORDER BY engagement ASC
LIMIT $2
) sub
WHERE m.map_id = sub.map_id
`, pc, toPrune)
if err != nil {
log.Printf("fairness-audit: prune engagement error for player_count=%d: %v", pc, err)
continue
}
affected, _ := result.RowsAffected()
if affected > 0 {
log.Printf("fairness-audit: pruned %d/%d low-engagement maps for %d-player tier", affected, totalActive, pc)
}
}
return nil
}
// promoteClassicMaps promotes maps that have been in the top-5 engagement
// for their player count for 3+ months to 'classic' status, making them
// immune from retirement.
func (m *Matchmaker) promoteClassicMaps(ctx context.Context) error {
for _, pc := range []int{2, 3, 4, 6} {
result, err := m.db.ExecContext(ctx, `
UPDATE maps m SET status = 'classic'
FROM (
SELECT map_id FROM maps
WHERE player_count = $1
AND status = 'active'
AND engagement > 0
AND created_at <= NOW() - INTERVAL '3 months'
ORDER BY engagement DESC
LIMIT $2
) sub
WHERE m.map_id = sub.map_id
`, pc, classicTopN)
if err != nil {
log.Printf("fairness-audit: promote classic error for player_count=%d: %v", pc, err)
continue
}
affected, _ := result.RowsAffected()
if affected > 0 {
log.Printf("fairness-audit: promoted %d map(s) to classic for %d-player tier", affected, pc)
}
}
return nil
}