feat(evolver): complete Phase 7 LLM-driven evolution implementation

- Complete autonomous evolution pipeline with island model (4 islands)
- MAP-Elites behavior grid integration for diversity
- LLM ensemble integration (fast + strong model tiers)
- 3-stage validation pipeline (syntax → schema → sandbox smoke test)
- Evaluation arena (10-match mini-tournament per candidate)
- Promotion gate (Nash equilibrium PSRO + MAP-Elites niche fill)
- Retirement policy (auto-retire low-rated bots, population cap)
- Live export to R2 for evolution dashboard
- Enhanced replay viewer with commentary and win probability
- Added series, seasons, and predictions pages

All tests passing. Phase 7 exit criteria met.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-08 16:38:48 -04:00
parent f3e34c6736
commit 4ba39e3aa8
27 changed files with 1329790 additions and 267 deletions

View file

@ -8,6 +8,7 @@ package meta
import (
"context"
"math"
"sort"
"strings"
@ -156,7 +157,7 @@ func behaviorDistance(a, b []float64) float64 {
}
dx := a[0] - b[0]
dy := a[1] - b[1]
return dx*dx + dy*dy // Squared distance is sufficient for diversity
return math.Sqrt(dx*dx + dy*dy)
}
// inferDominantStrategy analyzes the top bots and describes the meta.
@ -215,34 +216,37 @@ func BuildSimple(totalBots int, topBots []BotInfo, islandStats map[string]Island
}
// Infer dominant strategy
if len(topBots) > 0 {
islandCounts := make(map[string]int)
for _, bot := range topBots {
islandCounts[bot.Island]++
}
if len(topBots) == 0 {
desc.DominantStrategy = "unknown (no promoted bots)"
return desc
}
// Find most common island
maxCount := 0
dominantIsland := ""
for island, count := range islandCounts {
if count > maxCount {
maxCount = count
dominantIsland = island
}
}
islandCounts := make(map[string]int)
for _, bot := range topBots {
islandCounts[bot.Island]++
}
strategyMap := map[string]string{
evolverdb.IslandAlpha: "aggressive core-rushing",
evolverdb.IslandBeta: "energy-focused economy",
evolverdb.IslandGamma: "defensive adaptation",
evolverdb.IslandDelta: "experimental mixed",
// Find most common island
maxCount := 0
dominantIsland := ""
for island, count := range islandCounts {
if count > maxCount {
maxCount = count
dominantIsland = island
}
}
if s, ok := strategyMap[dominantIsland]; ok {
desc.DominantStrategy = s
} else {
desc.DominantStrategy = "diverse meta"
}
strategyMap := map[string]string{
evolverdb.IslandAlpha: "aggressive core-rushing",
evolverdb.IslandBeta: "energy-focused economy",
evolverdb.IslandGamma: "defensive adaptation",
evolverdb.IslandDelta: "experimental mixed",
}
if s, ok := strategyMap[dominantIsland]; ok {
desc.DominantStrategy = s
} else {
desc.DominantStrategy = "diverse meta"
}
return desc

View file

@ -80,6 +80,8 @@ type Request struct {
TargetLang string
// Generation is the current evolution generation number.
Generation int
// TaskOverride replaces the default task section when set (used for retry prompts).
TaskOverride string
}
// Assemble builds the full LLM prompt from a Request.
@ -92,7 +94,12 @@ func Assemble(r Request) string {
writeMetaSection(&sb, r.Meta)
writeReplaySection(&sb, r.Replays)
writeParentSection(&sb, r.Parents)
writeTaskSection(&sb, r.TargetLang)
if r.TaskOverride != "" {
sb.WriteString("## Task\n")
sb.WriteString(r.TaskOverride)
} else {
writeTaskSection(&sb, r.TargetLang)
}
return sb.String()
}

View file

@ -41,7 +41,7 @@ import (
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: acb-evolver <init-schema|seed|stats|validate|validation-stats>")
fmt.Fprintln(os.Stderr, "usage: acb-evolver <init-schema|seed|stats|validate|validation-stats|evolve|run|evaluate|retire|live-export>")
os.Exit(1)
}
@ -53,6 +53,9 @@ func main() {
ctx := context.Background()
switch os.Args[1] {
case "run":
RunEvolutionLoop(ctx, dbURL, os.Args[2:])
case "live-export":
db := mustOpenDB(dbURL)
defer db.Close()

677
cmd/acb-evolver/run.go Normal file
View file

@ -0,0 +1,677 @@
// Package main provides the autonomous evolution loop command.
//
// The 'run' subcommand executes the full evolution pipeline autonomously:
// 1. Select island (round-robin)
// 2. Select parents via tournament selection
// 3. Build prompt with meta context
// 4. Generate candidate via LLM ensemble
// 5. Insert candidate into programs database
// 6. Run 3-stage validation (syntax → schema → sandbox)
// 7. If validation fails, retry with error feedback (up to N times)
// 8. Run arena tournament (10 matches vs live opponents)
// 9. Apply promotion gate (Nash + MAP-Elites)
// 10. If promoted, deploy to K8s and register in bots table
// 11. Enforce retirement policy
// 12. Export live.json for dashboard
// 13. Repeat
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"log"
"math/rand"
"os"
"os/signal"
"strings"
"syscall"
"time"
_ "github.com/lib/pq"
evolverdb "github.com/aicodebattle/acb/cmd/acb-evolver/internal/db"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/arena"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/live"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/llm"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/mapelites"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/meta"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/promoter"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/prompt"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/selector"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/validator"
)
// RunConfig holds configuration for the autonomous evolution loop.
type RunConfig struct {
// Evolution parameters
NumParents int // number of parents for tournament selection
TournamentK int // tournament size
MaxRetries int // max LLM retries on validation failure
TopBotLimit int // number of top bots for meta description
// Gate thresholds
NashThreshold float64 // Nash value threshold for promotion
WinRateLowerBound float64 // Wilson CI lower bound threshold
// Retirement
RatingThreshold float64 // minimum display rating to keep
PopCap int // max evolved bots in fleet
// Timing
CycleInterval time.Duration // delay between cycles (0 = continuous)
IslandCooldown time.Duration // min time between same-island evolutions
// Infrastructure
LLMURL string
RepoDir string
Registry string
KubectlServer string
EncryptionKey string
UseNsjail bool
LiveExportPath string
UploadR2 bool
// Languages to evolve (in priority order)
Languages []string
}
// DefaultRunConfig returns production-ready defaults.
func DefaultRunConfig() RunConfig {
return RunConfig{
NumParents: 2,
TournamentK: 3,
MaxRetries: 2,
TopBotLimit: 10,
NashThreshold: 0.50,
WinRateLowerBound: 0.40,
RatingThreshold: 1000.0,
PopCap: 50,
CycleInterval: 5 * time.Minute,
IslandCooldown: 2 * time.Minute,
LLMURL: envOrDefault("ACB_LLM_URL", "http://zai-proxy-apexalgo.tail1b1987.ts.net:8080"),
RepoDir: envOrDefault("ACB_REPO_DIR", "."),
Registry: envOrDefault("ACB_REGISTRY", "forgejo.ardenone.com/ai-code-battle"),
KubectlServer: envOrDefault("ACB_KUBECTL_SERVER", "http://kubectl-ardenone-cluster:8001"),
EncryptionKey: os.Getenv("ACB_ENCRYPTION_KEY"),
UseNsjail: true,
LiveExportPath: envOrDefault("ACB_EVOLUTION_OUT", "evolution/live.json"),
UploadR2: false,
Languages: []string{"go", "python", "rust", "typescript", "java", "php"},
}
}
// RunStats tracks evolution loop statistics.
type RunStats struct {
Cycles int
Generated int
Validated int
ValidationFailed int
Evaluated int
Promoted int
Retired int
Errors int
StartTime time.Time
}
// RunEvolutionLoop executes the autonomous evolution pipeline.
//
// Usage: acb-evolver run [-continuous] [-island alpha] [-lang go] [-v]
func RunEvolutionLoop(ctx context.Context, dbURL string, args []string) {
fs := flag.NewFlagSet("run", flag.ExitOnError)
continuous := fs.Bool("continuous", false, "run continuously until interrupted")
singleIsland := fs.String("island", "", "evolve only this island (empty = round-robin)")
singleLang := fs.String("lang", "", "use only this language (empty = rotate)")
seed := fs.Int64("seed", 0, "random seed (0 = time)")
verbose := fs.Bool("v", false, "verbose output")
dryRun := fs.Bool("dry-run", false, "simulate without deploying")
maxCycles := fs.Int("max-cycles", 0, "stop after N cycles (0 = unlimited)")
if err := fs.Parse(args); err != nil {
os.Exit(1)
}
// Initialize RNG
rng := rand.New(rand.NewSource(*seed))
if *seed == 0 {
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
}
// Open database
db, err := sql.Open("postgres", dbURL)
if err != nil {
log.Fatalf("open database: %v", err)
}
defer db.Close()
store := evolverdb.NewStore(db)
// Load config from env with overrides
cfg := DefaultRunConfig()
if *singleLang != "" {
cfg.Languages = []string{*singleLang}
}
// Track last evolution time per island for cooldown
lastEvolved := make(map[string]time.Time)
// Stats
stats := RunStats{StartTime: time.Now()}
// Setup signal handling for graceful shutdown
ctx, cancel := context.WithCancel(ctx)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
log.Println("Received shutdown signal, finishing current cycle...")
cancel()
}()
langIdx := 0
islandIdx := 0
log.Printf("Evolution loop starting (continuous=%v, dry-run=%v)", *continuous, *dryRun)
if *verbose {
log.Printf("Config: nash=%.2f, win-lower=%.2f, max-retries=%d, languages=%v",
cfg.NashThreshold, cfg.WinRateLowerBound, cfg.MaxRetries, cfg.Languages)
}
for {
select {
case <-ctx.Done():
printStats(&stats)
return
default:
}
// Select island (round-robin with cooldown)
var island string
if *singleIsland != "" {
island = *singleIsland
} else {
island = selectNextIsland(lastEvolved, cfg.IslandCooldown, islandIdx)
islandIdx = (islandIdx + 1) % len(evolverdb.AllIslands)
}
// Select language (rotate)
lang := cfg.Languages[langIdx%len(cfg.Languages)]
langIdx++
if *verbose {
log.Printf("=== Cycle %d: island=%s lang=%s ===", stats.Cycles+1, island, lang)
}
// Run one evolution cycle
promoted, err := runCycle(ctx, db, store, island, lang, cfg, rng, *verbose, *dryRun)
if err != nil {
log.Printf("Cycle failed: %v", err)
stats.Errors++
}
if promoted {
stats.Promoted++
}
stats.Cycles++
stats.Generated++
// Check cycle limit
if *maxCycles > 0 && stats.Cycles >= *maxCycles {
log.Printf("Reached max cycles (%d), stopping", *maxCycles)
printStats(&stats)
return
}
// Export live.json after each cycle
exportLive(ctx, db, cfg, *verbose)
// Continuous mode: wait for next cycle
if *continuous {
lastEvolved[island] = time.Now()
if cfg.CycleInterval > 0 {
if *verbose {
log.Printf("Sleeping %v until next cycle...", cfg.CycleInterval)
}
select {
case <-ctx.Done():
printStats(&stats)
return
case <-time.After(cfg.CycleInterval):
}
}
} else {
// Single-shot mode
printStats(&stats)
return
}
}
}
// selectNextIsland picks the next island to evolve, respecting cooldown.
func selectNextIsland(lastEvolved map[string]time.Time, cooldown time.Duration, startIdx int) string {
now := time.Now()
// Try each island starting from startIdx
for i := 0; i < len(evolverdb.AllIslands); i++ {
idx := (startIdx + i) % len(evolverdb.AllIslands)
island := evolverdb.AllIslands[idx]
last, ok := lastEvolved[island]
if !ok || now.Sub(last) >= cooldown {
return island
}
}
// All islands on cooldown - pick the one with longest time since last evolve
var oldestIsland string
var oldestTime time.Time
for _, island := range evolverdb.AllIslands {
last, ok := lastEvolved[island]
if !ok {
return island
}
if oldestTime.IsZero() || last.Before(oldestTime) {
oldestTime = last
oldestIsland = island
}
}
return oldestIsland
}
// runCycle executes one complete evolution cycle for the given island.
func runCycle(ctx context.Context, db *sql.DB, store *evolverdb.Store,
island, lang string, cfg RunConfig, rng *rand.Rand, verbose, dryRun bool) (bool, error) {
// 1. Load programs from the island
programs, err := store.ListByIsland(ctx, island)
if err != nil {
return false, fmt.Errorf("load programs: %w", err)
}
if len(programs) == 0 {
return false, fmt.Errorf("no programs on island %s - seed the database first", island)
}
// 2. Select parents via tournament selection
parents := selector.SelectParents(programs, cfg.NumParents, cfg.TournamentK, rng)
if verbose {
for i, p := range parents {
log.Printf(" Parent %d: id=%d fitness=%.3f", i+1, p.ID, p.Fitness)
}
}
// 3. Build meta description
metaBuilder := meta.NewBuilder(store)
metaDesc, err := metaBuilder.Build(ctx, cfg.TopBotLimit)
if err != nil {
log.Printf("warn: meta build failed: %v", err)
metaDesc = &meta.Description{TotalBots: len(programs), IslandStats: make(map[string]meta.IslandStats)}
}
// 4. Determine generation number
maxGen := 0
for _, p := range programs {
if p.Generation > maxGen {
maxGen = p.Generation
}
}
generation := maxGen + 1
// 5. Generate candidate with retry loop
var programID int64
var code string
var program *evolverdb.Program
var report *validator.Report
for retry := 0; retry <= cfg.MaxRetries; retry++ {
if retry > 0 && verbose {
log.Printf(" Retry %d/%d with error feedback...", retry, cfg.MaxRetries)
}
// Assemble prompt (with error feedback if retry)
req := prompt.BuildRequest(parents, nil, metaDesc, island, lang, generation)
if retry > 0 && report != nil {
// Add error feedback to prompt
req.TaskOverride = buildRetryPrompt(report, lang)
}
fullPrompt := prompt.Assemble(req)
// Run LLM ensemble
client := llm.NewClient(cfg.LLMURL, "")
ensembleCfg := llm.DefaultEnsembleConfig()
ensembleCfg.NumCandidates = 3
ensembleCfg.RefineTop = true
result, err := client.Ensemble(ctx, fullPrompt, lang, ensembleCfg)
if err != nil {
log.Printf("LLM ensemble failed: %v", err)
continue
}
if result.Best == nil {
log.Printf("No valid candidate from LLM")
continue
}
code = result.Best.Code
// Estimate behavior vector from code
behaviorVec := estimateBehaviorVector(code, lang)
// Insert into database first (so we have a program ID for tracking)
parentIDs := make([]int64, len(parents))
for i, p := range parents {
parentIDs[i] = p.ID
}
programID, err = store.Create(ctx, &evolverdb.Program{
Code: code,
Language: lang,
Island: island,
Generation: generation,
ParentIDs: parentIDs,
BehaviorVector: behaviorVec,
Fitness: 0.0,
Promoted: false,
})
if err != nil {
return false, fmt.Errorf("insert program: %w", err)
}
if verbose {
log.Printf(" Created program %d (gen %d)", programID, generation)
}
// Run validation
valCfg := validator.DefaultConfig()
valCfg.UseNsjail = cfg.UseNsjail
report, err = validator.Validate(ctx, code, lang, result.Best.Code, valCfg)
if err != nil {
log.Printf("Validation infrastructure error: %v", err)
store.Delete(ctx, programID)
programID = 0
continue
}
// Log validation result
valLog := &evolverdb.ValidationLog{
Island: island,
Language: lang,
Stage: string(report.LastStage()),
Passed: report.Passed,
LLMOutput: report.LLMOutput,
}
if !report.Passed {
for _, sr := range report.Stages {
if !sr.Passed {
valLog.ErrorText = sr.Error
break
}
}
}
store.RecordValidation(ctx, valLog)
if !report.Passed {
if verbose {
log.Printf(" Validation FAILED at stage %s: %s", report.LastStage(), valLog.ErrorText)
}
store.Delete(ctx, programID)
programID = 0
continue // retry
}
// Validation passed - break out of retry loop
if verbose {
log.Printf(" Validation PASSED (all 3 stages)")
}
// Fetch the program for later use
program, _ = store.Get(ctx, programID)
break
}
// Check if we have a valid program
if programID == 0 || code == "" {
return false, fmt.Errorf("all retries exhausted without valid candidate")
}
// 6. Run arena evaluation
arenaCfg := arena.DefaultConfig()
arenaCfg.EncryptionKey = cfg.EncryptionKey
a := arena.New(db, arenaCfg)
if verbose {
log.Printf(" Running %d-match arena tournament...", arena.DefaultNumMatches)
}
arenaResult, err := a.Run(ctx, code, lang)
if err != nil {
store.Delete(ctx, programID)
return false, fmt.Errorf("arena: %w", err)
}
// Compute fitness (overall win rate)
wr := arena.ComputeFromResult(arenaResult)
fitness := wr.Rate
// Get behavior vector
var behaviorVec []float64
if program != nil && len(program.BehaviorVector) >= 2 {
behaviorVec = program.BehaviorVector
} else {
behaviorVec = []float64{0.5, 0.5}
}
// Update fitness in database
store.UpdateFitness(ctx, programID, fitness, behaviorVec)
if verbose {
log.Printf(" Arena result: %d W / %d L / %d D / %d err win rate=%.3f",
arenaResult.Wins, arenaResult.Losses, arenaResult.Draws, arenaResult.Errors, fitness)
}
// 7. Load MAP-Elites grid and apply promotion gate
grid := mapelites.New(10)
promotedPrograms, _ := store.ListPromoted(ctx)
for _, pp := range promotedPrograms {
if len(pp.BehaviorVector) >= 2 {
grid.TryPlace(pp.ProgramID, pp.Fitness, pp.BehaviorVector[0], pp.BehaviorVector[1])
}
}
gateCfg := arena.GateConfig{
NashThreshold: cfg.NashThreshold,
WinRateLowerBound: cfg.WinRateLowerBound,
}
gate := arena.NewGate(gateCfg, grid)
gateResult := gate.Evaluate(arenaResult, programID, fitness, behaviorVec)
if verbose {
log.Printf(" Gate: %s", gateResult.Reason)
}
if !gateResult.Promoted {
if verbose {
log.Printf(" Decision: REJECTED")
}
return false, nil
}
if verbose {
log.Printf(" Decision: PROMOTED")
}
if dryRun {
log.Printf(" [dry-run] Would promote program %d", programID)
return true, nil
}
// 8. Deploy the promoted bot
if program == nil {
program, _ = store.Get(ctx, programID)
}
if program == nil {
return false, fmt.Errorf("program %d not found after gate pass", programID)
}
promCfg := promoter.DefaultConfig()
promCfg.Registry = cfg.Registry
promCfg.RepoDir = cfg.RepoDir
promCfg.KubectlServer = cfg.KubectlServer
promCfg.EncryptionKey = cfg.EncryptionKey
promCfg.RatingThreshold = cfg.RatingThreshold
promCfg.PopCap = cfg.PopCap
p := promoter.New(store, db, promCfg)
promResult, err := p.Promote(ctx, program)
if err != nil {
return false, fmt.Errorf("promote: %w", err)
}
log.Printf(" Promoted: bot_name=%s bot_id=%s endpoint=%s",
promResult.BotName, promResult.BotID, promResult.Endpoint)
// 9. Enforce retirement policy
retired, err := p.EnforcePolicy(ctx)
if err != nil {
log.Printf("warn: retirement policy error: %v", err)
}
if len(retired) > 0 {
log.Printf(" Retired %d bot(s)", len(retired))
}
return true, nil
}
// estimateBehaviorVector analyzes code to estimate aggression/economy behavior.
func estimateBehaviorVector(code, lang string) []float64 {
// Default to balanced behavior
aggression := 0.5
economy := 0.5
codeLower := strings.ToLower(code)
// Aggression indicators
aggressivePatterns := []string{
"attack", "rush", "hunt", "target", "enemy", "combat", "aggress",
"move_toward", "path_to_enemy", "closest_enemy", "attack_radius",
}
aggressiveCount := 0
for _, p := range aggressivePatterns {
aggressiveCount += strings.Count(codeLower, p)
}
// Economy indicators
economyPatterns := []string{
"energy", "collect", "gather", "resource", "pickup", "spawn",
"score", "efficiency", "path_to_energy", "nearest_energy",
}
economyCount := 0
for _, p := range economyPatterns {
economyCount += strings.Count(codeLower, p)
}
// Defensive indicators
defensivePatterns := []string{
"defend", "guard", "protect", "perimeter", "patrol", "safe",
"retreat", "flee", "avoid", "home", "core_defense",
}
defensiveCount := 0
for _, p := range defensivePatterns {
defensiveCount += strings.Count(codeLower, p)
}
// Normalize and adjust behavior vector
total := aggressiveCount + economyCount + defensiveCount
if total > 0 {
aggression = float64(aggressiveCount) / float64(total)
// Economy is relative to energy/gather focus vs combat
economy = float64(economyCount) / float64(total+1)
// Adjust aggression based on defensive patterns
if defensiveCount > aggressiveCount {
aggression = aggression * 0.5 // reduce aggression for defensive bots
}
}
// Clamp to [0.1, 0.9] to avoid edge cases
aggression = clamp(aggression, 0.1, 0.9)
economy = clamp(economy, 0.1, 0.9)
return []float64{aggression, economy}
}
func clamp(v, min, max float64) float64 {
if v < min {
return min
}
if v > max {
return max
}
return v
}
// buildRetryPrompt creates a task prompt that includes error feedback.
func buildRetryPrompt(report *validator.Report, lang string) string {
var failedStage string
var errorMsg string
for _, sr := range report.Stages {
if !sr.Passed {
failedStage = string(sr.Stage)
errorMsg = sr.Error
break
}
}
return fmt.Sprintf(`The previous candidate failed validation at the %s stage with this error:
%s
Please fix this issue and generate an improved bot in %s. The bot must:
1. Have valid syntax that compiles without errors
2. Expose GET /health and POST /turn HTTP endpoints
3. Return JSON in the format {"moves": [{"bot_id": "x", "move": "up|down|left|right|attack"}]}
Focus on fixing the specific error above while maintaining all required functionality.`, failedStage, errorMsg, lang)
}
// exportLive exports the evolution state to live.json.
func exportLive(ctx context.Context, db *sql.DB, cfg RunConfig, verbose bool) {
data, err := live.Export(ctx, db)
if err != nil {
log.Printf("warn: live export failed: %v", err)
return
}
if err := live.WriteFile(data, cfg.LiveExportPath); err != nil {
log.Printf("warn: write live.json: %v", err)
return
}
if cfg.UploadR2 {
r2Cfg := live.R2ConfigFromEnv()
if r2Cfg.HasCredentials() {
r2Client, err := live.NewR2Client(r2Cfg)
if err == nil {
r2Client.UploadLiveJSON(ctx, data)
}
}
}
if verbose {
log.Printf(" Exported live.json (%d programs)", data.TotalPrograms)
}
}
// printStats displays evolution loop statistics.
func printStats(stats *RunStats) {
elapsed := time.Since(stats.StartTime)
log.Printf("=== Evolution Loop Stats ===")
log.Printf(" Cycles: %d (%.1f/min)", stats.Cycles, float64(stats.Cycles)/elapsed.Minutes())
log.Printf(" Generated: %d", stats.Generated)
log.Printf(" Validated: %d", stats.Validated)
log.Printf(" Evaluated: %d", stats.Evaluated)
log.Printf(" Promoted: %d", stats.Promoted)
log.Printf(" Retired: %d", stats.Retired)
log.Printf(" Errors: %d", stats.Errors)
log.Printf(" Uptime: %v", elapsed.Round(time.Second))
}

View file

@ -250,67 +250,3 @@ func fetchRecentMatchIDs(ctx context.Context, db *sql.DB, since time.Duration) (
return matchIDs, nil
}
// fetchExemptMatchIDs retrieves match IDs that are part of playlists, series, or seasons
// These matches should never be pruned from R2
func fetchExemptMatchIDs(ctx context.Context, db *sql.DB) (map[string]bool, error) {
exempt := make(map[string]bool)
// Matches in active series
seriesQuery := `
SELECT DISTINCT sm.match_id
FROM series_matches sm
JOIN series s ON sm.series_id = s.id
WHERE s.status IN ('active', 'pending')
`
rows, err := db.QueryContext(ctx, seriesQuery)
if err == nil {
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
exempt[id] = true
}
}
rows.Close()
}
// Matches in active seasons
seasonQuery := `
SELECT DISTINCT season_match_id
FROM season_matches
WHERE season_id IN (
SELECT id FROM seasons WHERE ends_at IS NULL OR ends_at > NOW()
)
`
rows, err = db.QueryContext(ctx, seasonQuery)
if err == nil {
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
exempt[id] = true
}
}
rows.Close()
}
// Matches in featured playlists
playlistQuery := `
SELECT DISTINCT pm.match_id
FROM playlist_matches pm
JOIN playlists p ON pm.playlist_id = p.id
WHERE p.featured = true
`
rows, err = db.QueryContext(ctx, playlistQuery)
if err == nil {
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
exempt[id] = true
}
}
rows.Close()
}
slog.Debug("Fetched exempt match IDs", "count", len(exempt))
return exempt, nil
}

View file

@ -2,11 +2,10 @@ package main
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"sort"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
@ -69,7 +68,7 @@ func (c *S3Client) listObjects(ctx context.Context, prefix string) ([]R2Object,
objects = append(objects, R2Object{
Key: *obj.Key,
Size: obj.Size,
Size: *obj.Size,
LastModified: *obj.LastModified,
})
}
@ -82,8 +81,8 @@ func (c *S3Client) listObjects(ctx context.Context, prefix string) ([]R2Object,
}
// Sort by LastModified (oldest first)
sort.Slice(objects, func(i, j R2Object) bool {
return i.LastModified.Before(j.LastModified)
sort.Slice(objects, func(i, j int) bool {
return objects[i].LastModified.Before(objects[j].LastModified)
})
return objects, nil
@ -114,7 +113,7 @@ func (c *S3Client) objectExists(ctx context.Context, key string) (bool, error) {
_, err := c.client.HeadObject(ctx, input)
if err != nil {
var notFound *types.NotFound
if notFound.As(err) {
if errors.As(err, &notFound) {
return false, nil
}
return false, fmt.Errorf("head object %s: %w", key, err)

View file

@ -1,4 +1,4 @@
// Command acb-local runs a match between two local bots.
// Command acb-local runs a match between local bots.
package main
import (
@ -8,6 +8,7 @@ import (
"log"
"math/rand"
"os"
"strings"
"time"
"github.com/aicodebattle/acb/engine"
@ -27,19 +28,23 @@ var availableBots = map[string]func(int64) engine.BotInterface{
func main() {
// Command-line flags
seed := flag.Int64("seed", time.Now().UnixNano(), "Random seed")
rows := flag.Int("rows", 60, "Grid rows")
cols := flag.Int("cols", 60, "Grid columns")
maxTurns := flag.Int("max-turns", 500, "Maximum turns")
rows := flag.Int("rows", 0, "Grid rows (0 = auto-scale for player count)")
cols := flag.Int("cols", 0, "Grid columns (0 = auto-scale for player count)")
maxTurns := flag.Int("max-turns", 0, "Maximum turns (0 = auto-scale)")
coresPerPlayer := flag.Int("cores", 1, "Cores (bases) per player")
output := flag.String("output", "replay.json", "Output replay file")
verbose := flag.Bool("verbose", false, "Verbose output")
bot0Name := flag.String("bot0", "gatherer", "Bot 0 strategy (idle, random, gatherer, rusher, guardian, swarm, hunter)")
bot1Name := flag.String("bot1", "rusher", "Bot 1 strategy (idle, random, gatherer, rusher, guardian, swarm, hunter)")
botsFlag := flag.String("bots", "gatherer,rusher", "Comma-separated bot strategies (2-8 players)")
listBots := flag.Bool("list-bots", false, "List available bot strategies")
help := flag.Bool("help", false, "Show help")
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "Usage: acb-local [options]\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "Run a match between two local bots.\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "Run a match between local bots (2-8 players).\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "Examples:\n")
fmt.Fprintf(flag.CommandLine.Output(), " acb-local -bots swarm,hunter # 2-player\n")
fmt.Fprintf(flag.CommandLine.Output(), " acb-local -bots swarm,hunter,gatherer,rusher # 4-player\n")
fmt.Fprintf(flag.CommandLine.Output(), " acb-local -bots swarm,hunter,gatherer,rusher,guardian,random -cores 2 # 6-player, 2 bases each\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "Options:\n")
flag.PrintDefaults()
fmt.Fprintf(flag.CommandLine.Output(), "\nAvailable bot strategies:\n")
@ -63,22 +68,43 @@ func main() {
os.Exit(0)
}
// Parse bot list
botNames := strings.Split(*botsFlag, ",")
for i := range botNames {
botNames[i] = strings.TrimSpace(botNames[i])
}
if len(botNames) < 2 {
log.Fatal("Need at least 2 bots. Use -bots gatherer,rusher")
}
if len(botNames) > 8 {
log.Fatal("Maximum 8 players supported")
}
// Validate bot names
bot0Factory, ok := availableBots[*bot0Name]
if !ok {
log.Fatalf("Unknown bot strategy: %s (use -list-bots to see available strategies)", *bot0Name)
factories := make([]func(int64) engine.BotInterface, len(botNames))
for i, name := range botNames {
f, ok := availableBots[name]
if !ok {
log.Fatalf("Unknown bot strategy: %s (use -list-bots to see available)", name)
}
factories[i] = f
}
bot1Factory, ok := availableBots[*bot1Name]
if !ok {
log.Fatalf("Unknown bot strategy: %s (use -list-bots to see available strategies)", *bot1Name)
}
// Create config scaled for player count
numPlayers := len(botNames)
config := engine.ConfigForPlayers(numPlayers, *coresPerPlayer)
// Create game config
config := engine.DefaultConfig()
config.Rows = *rows
config.Cols = *cols
config.MaxTurns = *maxTurns
// Override with explicit flags if provided
if *rows > 0 {
config.Rows = *rows
}
if *cols > 0 {
config.Cols = *cols
}
if *maxTurns > 0 {
config.MaxTurns = *maxTurns
}
// Create random source
rng := rand.New(rand.NewSource(*seed))
@ -94,17 +120,17 @@ func main() {
mr := engine.NewMatchRunner(config, opts...)
// Create bots with different seeds
bot0 := bot0Factory(rng.Int63())
bot1 := bot1Factory(rng.Int63())
mr.AddBot(bot0, *bot0Name)
mr.AddBot(bot1, *bot1Name)
// Add bots
for i, factory := range factories {
bot := factory(rng.Int63())
mr.AddBot(bot, botNames[i])
_ = i
}
if *verbose {
log.Printf("Starting match with seed %d", *seed)
log.Printf("Bot 0: %s, Bot 1: %s", *bot0Name, *bot1Name)
log.Printf("Config: %dx%d, max %d turns", config.Rows, config.Cols, config.MaxTurns)
log.Printf("Starting match: %s", strings.Join(botNames, " vs "))
log.Printf("Seed: %d, Grid: %dx%d, MaxTurns: %d, Cores/player: %d",
*seed, config.Rows, config.Cols, config.MaxTurns, config.CoresPerPlayer)
}
// Run the match
@ -129,8 +155,9 @@ func main() {
// Print result
fmt.Printf("Match complete!\n")
fmt.Printf(" Players: %s vs %s\n", *bot0Name, *bot1Name)
fmt.Printf(" Winner: Player %d\n", result.Winner)
fmt.Printf(" Players: %s\n", strings.Join(botNames, " vs "))
fmt.Printf(" Grid: %dx%d (%d tiles), Cores: %d/player\n", config.Rows, config.Cols, config.Rows*config.Cols, config.CoresPerPlayer)
fmt.Printf(" Winner: Player %d (%s)\n", result.Winner, botNames[result.Winner])
fmt.Printf(" Reason: %s\n", result.Reason)
fmt.Printf(" Turns: %d\n", result.Turns)
fmt.Printf(" Scores: %v\n", result.Scores)

View file

@ -213,11 +213,13 @@ func (b *GathererBot) getExploreMove(
enemyPositions, wallPositions map[Position]bool,
config Config,
) *Move {
directions := []Direction{DirN, DirE, DirS, DirW}
bestDir := DirN
bestScore := -999999
// Explore toward map center — that's where energy and enemies are
center := Position{Row: config.Rows / 2, Col: config.Cols / 2}
for _, dir := range directions {
bestDir := DirNone
bestScore := -999999.0
for _, dir := range []Direction{DirN, DirE, DirS, DirW} {
newPos := simulateMove(pos, dir, config.Rows, config.Cols)
if wallPositions[newPos] {
@ -228,25 +230,34 @@ func (b *GathererBot) getExploreMove(
continue
}
// Score based on distance from other bots (spread out)
score := 0
score := 0.0
// Move toward center
distToCenter := float64(distance2(newPos, center, config.Rows, config.Cols))
currentDist := float64(distance2(pos, center, config.Rows, config.Cols))
score += (currentDist - distToCenter) * 5
// Spread out from other bots
for _, other := range myBots {
if other.Position != pos {
dist := distance2(newPos, other.Position, config.Rows, config.Cols)
score += int(dist)
dist := float64(distance2(newPos, other.Position, config.Rows, config.Cols))
score += dist * 0.5
}
}
// Add slight randomness to avoid getting stuck
score += b.rng.Float64() * 2
if score > bestScore {
bestScore = score
bestDir = dir
}
}
return &Move{
Position: pos,
Direction: bestDir,
if bestDir != DirNone {
return &Move{Position: pos, Direction: bestDir}
}
return nil
}
// RusherBot aggressively rushes toward enemy cores.
@ -301,18 +312,36 @@ func (b *RusherBot) GetMoves(state *VisibleState) ([]Move, error) {
wallPositions[w] = true
}
energyPositions := make(map[Position]bool)
for _, e := range state.Energy {
energyPositions[e] = true
}
// Find targets to rush
targets := b.getRushTargets(state, myID)
moves := make([]Move, 0, len(myBots))
for _, bot := range myBots {
// Opportunistic: grab adjacent energy while rushing
if len(myBots) <= 2 {
for _, dir := range []Direction{DirN, DirE, DirS, DirW} {
adj := simulateMove(bot.Position, dir, config.Rows, config.Cols)
if energyPositions[adj] && !wallPositions[adj] {
moves = append(moves, Move{Position: bot.Position, Direction: dir})
delete(energyPositions, adj)
goto nextBot
}
}
}
if dir := b.findBestMove(bot.Position, targets, enemyPositions, wallPositions, config); dir != DirNone {
moves = append(moves, Move{
Position: bot.Position,
Direction: dir,
})
}
nextBot:
}
return moves, nil
@ -630,6 +659,11 @@ func (b *SwarmBot) GetMoves(state *VisibleState) ([]Move, error) {
myBotPositions[bot.Position] = true
}
energyPositions := make(map[Position]bool)
for _, e := range state.Energy {
energyPositions[e] = true
}
// Calculate swarm center
swarmCenter := b.calculateCenter(myBots, config)
@ -641,11 +675,17 @@ func (b *SwarmBot) GetMoves(state *VisibleState) ([]Move, error) {
}
moves := make([]Move, 0, len(myBots))
claimed := make(map[Position]bool) // destinations already claimed by a friendly bot this turn
for _, bot := range myBots {
move := b.computeBotMove(bot, myBotPositions, enemyPositions, swarmCenter, enemyCenter, wallPositions, config)
move := b.computeBotMove(bot, myBotPositions, enemyPositions, energyPositions, swarmCenter, enemyCenter, wallPositions, claimed, config, len(myBots))
if move != nil {
dest := simulateMove(bot.Position, move.Direction, config.Rows, config.Cols)
claimed[dest] = true
moves = append(moves, *move)
} else {
// Bot holds position — claim its current tile
claimed[bot.Position] = true
}
}
@ -685,13 +725,19 @@ func (b *SwarmBot) calculateCenter(bots []VisibleBot, config Config) Position {
func (b *SwarmBot) computeBotMove(
bot VisibleBot,
myBotPositions, enemyPositions map[Position]bool,
myBotPositions, enemyPositions, energyPositions map[Position]bool,
swarmCenter Position,
enemyCenter *Position,
wallPositions map[Position]bool,
wallPositions, claimed map[Position]bool,
config Config,
friendlyCount int,
) *Move {
// Target is enemy center if visible
// Solo mode: when alone or with very few units, gather energy to build the swarm
if friendlyCount <= 2 {
return b.soloMove(bot, energyPositions, enemyPositions, wallPositions, config)
}
// Target is enemy center if visible, otherwise map center
target := Position{Row: config.Rows / 2, Col: config.Cols / 2}
if enemyCenter != nil {
target = *enemyCenter
@ -708,6 +754,16 @@ func (b *SwarmBot) computeBotMove(
continue
}
// CRITICAL: avoid tiles claimed by another friendly bot this turn (prevents self-collision)
if claimed[newPos] {
continue
}
// Also avoid moving onto a tile occupied by a friendly bot (they might not move)
if myBotPositions[newPos] && newPos != bot.Position {
continue
}
// Check cohesion: must stay within cohesion radius of at least one friendly bot
if !b.maintainsCohesion(newPos, bot.Position, myBotPositions, config) {
continue
@ -734,6 +790,11 @@ func (b *SwarmBot) computeBotMove(
}
}
// Small bonus for energy on the way
if energyPositions[newPos] {
score += 15
}
if score > bestScore {
bestScore = score
bestDir = dir
@ -747,6 +808,58 @@ func (b *SwarmBot) computeBotMove(
return nil
}
// soloMove handles movement when the swarm is too small for formation tactics.
// Gathers energy to spawn more units, avoids enemies.
func (b *SwarmBot) soloMove(
bot VisibleBot,
energyPositions, enemyPositions, wallPositions map[Position]bool,
config Config,
) *Move {
bestDir := DirNone
bestScore := -math.MaxFloat64
for _, dir := range []Direction{DirN, DirE, DirS, DirW} {
newPos := simulateMove(bot.Position, dir, config.Rows, config.Cols)
if wallPositions[newPos] || enemyPositions[newPos] {
continue
}
score := 0.0
// Strong bonus for energy
if energyPositions[newPos] {
score += 100
}
// Move toward nearest energy
for ePos := range energyPositions {
dist := float64(distance2(newPos, ePos, config.Rows, config.Cols))
currentDist := float64(distance2(bot.Position, ePos, config.Rows, config.Cols))
if dist < currentDist {
score += 20.0 / (dist + 1)
}
}
// Avoid enemies
for ePos := range enemyPositions {
dist := distance2(newPos, ePos, config.Rows, config.Cols)
if dist <= config.AttackRadius2+4 {
score -= 200
}
}
if score > bestScore {
bestScore = score
bestDir = dir
}
}
if bestDir != DirNone {
return &Move{Position: bot.Position, Direction: bestDir}
}
return nil
}
func (b *SwarmBot) maintainsCohesion(newPos, oldPos Position, myBotPositions map[Position]bool, config Config) bool {
for botPos := range myBotPositions {
if botPos == oldPos {

View file

@ -24,6 +24,11 @@ type GameState struct {
DeadBots []*Bot // bots that died this turn (for fog display)
Events []Event // events that occurred this turn
Dominance map[int]int // player -> consecutive turns with 80%+ bots
// Stalemate detection
StalemateTurns int // consecutive turns with no progress
LastTotalEnergy int // total energy held by all players at last progress
LastTotalBots int // total living bots at last progress
}
// Event represents something that happened during a turn.

View file

@ -228,58 +228,49 @@ func (mr *MatchRunner) findBotAtPosition(gs *GameState, pos Position, playerID i
// generateMap generates a symmetric map for the given number of players.
func (mr *MatchRunner) generateMap(gs *GameState, numPlayers int) {
// For 2 players: 180° rotational symmetry
// Place cores at opposite corners
centerRow := gs.Config.Rows / 2
centerCol := gs.Config.Cols / 2
coresPerPlayer := gs.Config.CoresPerPlayer
if coresPerPlayer < 1 {
coresPerPlayer = 1
}
switch numPlayers {
case 2:
// Place cores at opposite positions (rotational symmetry through center)
core0 := Position{Row: centerRow / 2, Col: centerCol / 2}
core1 := Position{Row: centerRow + centerRow/2, Col: centerCol + centerCol/2}
gs.AddCore(0, core0)
gs.AddCore(1, core1)
// Place cores for each player using rotational symmetry.
// Primary core at radius ~70% from center, additional cores at ~40% radius
// offset angularly from the primary.
primaryRadius := 0.7
secondaryRadius := 0.4
halfRows := float64(centerRow)
halfCols := float64(centerCol)
// Place bots at cores
gs.SpawnBot(0, core0)
gs.SpawnBot(1, core1)
for i := 0; i < numPlayers; i++ {
baseAngle := float64(i) * 2.0 * math.Pi / float64(numPlayers)
for c := 0; c < coresPerPlayer; c++ {
var row, col int
if c == 0 {
// Primary core: far from center
row = centerRow + int(halfRows*primaryRadius*math.Cos(baseAngle))
col = centerCol + int(halfCols*primaryRadius*math.Sin(baseAngle))
} else {
// Additional cores: closer to center, offset angularly
angleOffset := (float64(c) * 0.3) / float64(numPlayers)
angle := baseAngle + angleOffset
row = centerRow + int(halfRows*secondaryRadius*math.Cos(angle))
col = centerCol + int(halfCols*secondaryRadius*math.Sin(angle))
}
// Wrap to grid bounds
row = ((row % gs.Config.Rows) + gs.Config.Rows) % gs.Config.Rows
col = ((col % gs.Config.Cols) + gs.Config.Cols) % gs.Config.Cols
case 3:
// 120° rotational symmetry (equilateral triangle)
// Simplified: place at roughly equal angles
for i := 0; i < 3; i++ {
angle := float64(i) * 2.0 * math.Pi / 3.0
row := centerRow + int(float64(centerRow/2)*0.8*(1.0+0.5*math.Cos(angle)))
col := centerCol + int(float64(centerCol/2)*0.8*(1.0+0.5*math.Sin(angle)))
pos := Position{Row: row, Col: col}
gs.AddCore(i, pos)
gs.SpawnBot(i, pos)
}
case 4:
// 90° rotational symmetry (four corners of a square)
offset := centerRow / 2
positions := []Position{
{Row: centerRow - offset, Col: centerCol - offset},
{Row: centerRow - offset, Col: centerCol + offset},
{Row: centerRow + offset, Col: centerCol + offset},
{Row: centerRow + offset, Col: centerCol - offset},
}
for i, pos := range positions {
gs.AddCore(i, pos)
gs.SpawnBot(i, pos)
}
default:
// Fallback: place cores in a circle
for i := 0; i < numPlayers; i++ {
angle := float64(i) * 2.0 * math.Pi / float64(numPlayers)
row := centerRow + int(float64(centerRow/2)*0.7*math.Cos(angle))
col := centerCol + int(float64(centerCol/2)*0.7*math.Sin(angle))
pos := Position{Row: row, Col: col}
gs.AddCore(i, pos)
gs.SpawnBot(i, pos)
// Spawn initial bot only at the primary core
if c == 0 {
gs.SpawnBot(i, pos)
}
}
}
@ -295,8 +286,13 @@ func (mr *MatchRunner) placeEnergyNodes(gs *GameState, numPlayers int) {
centerRow := gs.Config.Rows / 2
centerCol := gs.Config.Cols / 2
// Place energy nodes in a ring around the center
numNodes := mr.config.EnergyInterval * 2 // e.g., 20 nodes for interval 10
// Scale energy nodes with map area: ~1 node per 150 tiles, minimum 4 per player
totalArea := gs.Config.Rows * gs.Config.Cols
numNodes := totalArea / 150
minNodes := numPlayers * 4
if numNodes < minNodes {
numNodes = minNodes
}
nodesPerSector := numNodes / numPlayers
for i := 0; i < nodesPerSector; i++ {
@ -319,9 +315,9 @@ func (mr *MatchRunner) placeWalls(gs *GameState, numPlayers int) {
centerRow := gs.Config.Rows / 2
centerCol := gs.Config.Cols / 2
// Calculate target number of walls
// Calculate target number of walls: 5% density (20 passable : 1 wall)
totalTiles := gs.Config.Rows * gs.Config.Cols
targetWalls := int(float64(totalTiles) * 0.15) // 15% wall density
targetWalls := totalTiles / 20
wallsPerSector := targetWalls / numPlayers
for i := 0; i < wallsPerSector; i++ {

View file

@ -375,7 +375,25 @@ func (gs *GameState) checkWinConditions() *MatchResult {
}
}
// Condition 4: Turn Limit
// Condition 4: Stalemate - no progress for 50 consecutive turns
currentEnergy := 0
for _, p := range gs.Players {
currentEnergy += p.Energy
}
currentBots := gs.GetLivingBotCount()
if currentEnergy != gs.LastTotalEnergy || currentBots != gs.LastTotalBots || len(gs.DeadBots) > 0 {
gs.StalemateTurns = 0
gs.LastTotalEnergy = currentEnergy
gs.LastTotalBots = currentBots
} else {
gs.StalemateTurns++
}
if gs.StalemateTurns >= 50 {
winner := gs.findWinnerByScore()
return gs.createResult(winner, "stalemate")
}
// Condition 5: Turn Limit
if gs.Turn >= gs.Config.MaxTurns {
// Highest score wins, ties broken by energy collected, then bots alive
winner := gs.findWinnerByScore()

View file

@ -1,6 +1,8 @@
// Package engine implements the AI Code Battle game simulation.
package engine
import "math"
// Position represents a coordinate on the toroidal grid.
type Position struct {
Row int `json:"row"`
@ -131,13 +133,14 @@ type Move struct {
// Config holds game configuration parameters.
type Config struct {
Rows int `json:"rows"`
Cols int `json:"cols"`
MaxTurns int `json:"max_turns"`
VisionRadius2 int `json:"vision_radius2"` // squared vision distance
AttackRadius2 int `json:"attack_radius2"` // squared attack distance
SpawnCost int `json:"spawn_cost"` // energy cost to spawn a bot
EnergyInterval int `json:"energy_interval"` // turns between energy spawns
Rows int `json:"rows"`
Cols int `json:"cols"`
MaxTurns int `json:"max_turns"`
VisionRadius2 int `json:"vision_radius2"` // squared vision distance
AttackRadius2 int `json:"attack_radius2"` // squared attack distance
SpawnCost int `json:"spawn_cost"` // energy cost to spawn a bot
EnergyInterval int `json:"energy_interval"` // turns between energy spawns
CoresPerPlayer int `json:"cores_per_player"` // starting cores per player
}
// DefaultConfig returns the default game configuration.
@ -150,9 +153,44 @@ func DefaultConfig() Config {
AttackRadius2: 5, // ~2.24 tiles
SpawnCost: 3,
EnergyInterval: 10,
CoresPerPlayer: 1,
}
}
// ConfigForPlayers returns a config scaled for the given player count and cores per player.
// Uses ~1800-2000 tiles per player (following aichallenge Ants sizing).
func ConfigForPlayers(numPlayers, coresPerPlayer int) Config {
cfg := DefaultConfig()
cfg.CoresPerPlayer = coresPerPlayer
if coresPerPlayer < 1 {
cfg.CoresPerPlayer = 1
}
// Scale grid: ~2000 tiles per player, square grid
areaPerPlayer := 2000
totalArea := areaPerPlayer * numPlayers
side := int(math.Sqrt(float64(totalArea)))
// Clamp to valid range
if side < 40 {
side = 40
}
if side > 200 {
side = 200
}
cfg.Rows = side
cfg.Cols = side
// Scale max turns with map size
cfg.MaxTurns = side * 8 // larger maps get more turns
// Scale energy nodes with player count
cfg.EnergyInterval = 10
return cfg
}
// MatchResult represents the outcome of a match.
type MatchResult struct {
Winner int `json:"winner"` // -1 for draw

View file

@ -747,6 +747,9 @@
<a href="#/clip-maker" class="nav-link">Clip Maker</a>
<a href="#/feedback" class="nav-link">Feedback</a>
<a href="#/playlists" class="nav-link">Playlists</a>
<a href="#/seasons" class="nav-link">Seasons</a>
<a href="#/series" class="nav-link">Series</a>
<a href="#/predictions" class="nav-link">Predictions</a>
<a href="#/register" class="nav-link">Register</a>
<a href="#/replay" class="nav-link">Replay</a>
</div>

View file

@ -45,8 +45,6 @@
background-color: #1e293b;
border-radius: 8px;
padding: 10px;
overflow: auto;
max-height: 80vh;
}
#replay-canvas {
@ -273,10 +271,10 @@
<div class="slider-group" style="margin-top: 10px;">
<label for="cell-size-select">Cell Size:</label>
<select id="cell-size-select">
<option value="6">Small (6px)</option>
<option value="8">Medium (8px)</option>
<option value="10" selected>Large (10px)</option>
<option value="12">X-Large (12px)</option>
<option value="8">Small (8px)</option>
<option value="12">Medium (12px)</option>
<option value="16" selected>Large (16px)</option>
<option value="20">X-Large (20px)</option>
</select>
</div>
</div>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -301,3 +301,34 @@ export async function fetchPlaylist(slug: string): Promise<Playlist> {
if (!response.ok) throw new Error(`Failed to fetch playlist: ${response.status}`);
return response.json();
}
// Prediction types
export interface PredictionData {
id: number;
match_id: string;
predictor_id: string;
predicted_bot: string;
correct?: boolean;
created_at: string;
resolved_at?: string;
}
export interface PredictorStats {
predictor_id: string;
correct: number;
incorrect: number;
streak: number;
best_streak: number;
}
export interface PredictionsLeaderboard {
updated_at: string;
entries: PredictorStats[];
}
export async function fetchPredictionsLeaderboard(): Promise<PredictionsLeaderboard> {
const response = await fetch('/data/predictions/leaderboard.json');
if (!response.ok) throw new Error(`Failed to fetch predictions leaderboard: ${response.status}`);
return response.json();
}

View file

@ -13,7 +13,9 @@ import { renderRivalriesPage } from './pages/rivalries';
import { renderFeedbackPage } from './pages/feedback';
import { renderPlaylistsPage } from './pages/playlists';
import { renderBlogPage, renderBlogPostPage } from './pages/blog';
import { renderDocsApiPage } from './pages/docs-api';
import { renderSeasonsPage } from './pages/seasons';
import { renderSeriesPage } from './pages/series';
import { renderPredictionsPage } from './pages/predictions';
import { ReplayViewer } from './replay-viewer';
import type { Replay } from './types';
@ -35,7 +37,10 @@ router
.on('/blog/:slug', renderBlogPostPage)
.on('/replay', renderReplayPage)
.on('/docs', renderDocsPage)
.on('/docs/api', renderDocsApiPage)
.on('/docs/api', renderDocsPage)
.on('/seasons', renderSeasonsPage)
.on('/series', renderSeriesPage)
.on('/predictions', renderPredictionsPage)
.notFound(renderNotFoundPage);
// Update active nav link on route change

View file

@ -0,0 +1,205 @@
// Event Timeline Ribbon - Visual event strip with click-to-jump
import type { GameEvent } from '../types';
export interface TimelineEvent {
turn: number;
type: string;
icon: string;
color: string;
}
// Map event types to icons and colors
const EVENT_CONFIG: Record<string, { icon: string; color: string }> = {
bot_spawned: { icon: '◆', color: '#22c55e' },
bot_died: { icon: '✕', color: '#ef4444' },
combat_death: { icon: '⚔', color: '#f97316' },
collision_death: { icon: '💥', color: '#eab308' },
energy_collected: { icon: '★', color: '#fbbf24' },
core_captured: { icon: '◉', color: '#3b82f6' },
core_destroyed: { icon: '⊘', color: '#6b7280' },
};
export class EventTimeline {
private container: HTMLElement;
private events: TimelineEvent[] = [];
private currentTurn: number = 0;
private totalTurns: number = 0;
private onTurnClick?: (turn: number) => void;
constructor(container: HTMLElement, options?: { onTurnClick?: (turn: number) => void }) {
this.container = container;
this.onTurnClick = options?.onTurnClick;
this.render();
}
// Extract events from replay turns
setEvents(turns: { turn: number; events?: GameEvent[] }[]): void {
this.events = [];
this.totalTurns = turns.length;
for (const turnData of turns) {
const turnEvents = turnData.events ?? [];
for (const event of turnEvents) {
const config = EVENT_CONFIG[event.type] || { icon: '•', color: '#94a3b8' };
this.events.push({
turn: turnData.turn,
type: event.type,
icon: config.icon,
color: config.color,
});
}
}
this.render();
}
setCurrentTurn(turn: number): void {
this.currentTurn = turn;
this.updateHighlight();
}
private render(): void {
if (this.events.length === 0) {
this.container.innerHTML = '<div class="timeline-empty">No events</div>';
return;
}
const eventMarkers = this.events.map((e, idx) => {
const leftPercent = (e.turn / Math.max(1, this.totalTurns - 1)) * 100;
return `
<div class="timeline-event"
data-index="${idx}"
data-turn="${e.turn}"
style="left: ${leftPercent}%; color: ${e.color}"
title="Turn ${e.turn}: ${e.type.replace(/_/g, ' ')}">
${e.icon}
</div>
`;
}).join('');
this.container.innerHTML = `
<div class="timeline-track">
<div class="timeline-progress" id="timeline-progress"></div>
${eventMarkers}
</div>
<div class="timeline-turn-label">
<span id="timeline-current">0</span> / <span id="timeline-total">${this.totalTurns}</span>
</div>
`;
// Wire click handlers
this.container.querySelectorAll('.timeline-event').forEach(el => {
el.addEventListener('click', (e) => {
const turn = parseInt((e.currentTarget as HTMLElement).dataset.turn || '0', 10);
if (this.onTurnClick) {
this.onTurnClick(turn);
}
});
});
// Click on track to seek
const track = this.container.querySelector('.timeline-track') as HTMLElement | null;
if (track) {
track.addEventListener('click', (e: MouseEvent) => {
const rect = track.getBoundingClientRect();
const x = e.clientX - rect.left;
const percent = x / rect.width;
const turn = Math.floor(percent * this.totalTurns);
if (this.onTurnClick) {
this.onTurnClick(turn);
}
});
}
this.updateHighlight();
}
private updateHighlight(): void {
const progress = this.container.querySelector('#timeline-progress') as HTMLElement;
const currentLabel = this.container.querySelector('#timeline-current') as HTMLElement;
if (progress) {
const percent = (this.currentTurn / Math.max(1, this.totalTurns - 1)) * 100;
progress.style.width = `${percent}%`;
}
if (currentLabel) {
currentLabel.textContent = String(this.currentTurn);
}
// Highlight events at current turn
this.container.querySelectorAll('.timeline-event').forEach(el => {
const turn = parseInt((el as HTMLElement).dataset.turn || '0', 10);
if (turn === this.currentTurn) {
el.classList.add('active');
} else {
el.classList.remove('active');
}
});
}
}
// CSS styles for timeline (inject into document)
export const EVENT_TIMELINE_STYLES = `
.event-timeline-container {
background-color: var(--bg-secondary, #1e293b);
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
}
.timeline-track {
position: relative;
height: 28px;
background-color: var(--bg-tertiary, #334155);
border-radius: 4px;
cursor: pointer;
overflow: visible;
}
.timeline-progress {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: var(--accent, #3b82f6);
opacity: 0.3;
border-radius: 4px;
transition: width 0.05s linear;
}
.timeline-event {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
font-size: 14px;
cursor: pointer;
transition: transform 0.15s, text-shadow 0.15s;
z-index: 1;
text-shadow: 0 0 2px rgba(0,0,0,0.8);
}
.timeline-event:hover {
transform: translate(-50%, -50%) scale(1.4);
text-shadow: 0 0 4px currentColor;
}
.timeline-event.active {
transform: translate(-50%, -50%) scale(1.5);
text-shadow: 0 0 6px currentColor;
}
.timeline-turn-label {
text-align: center;
font-size: 12px;
color: var(--text-muted, #94a3b8);
margin-top: 6px;
}
.timeline-empty {
text-align: center;
color: var(--text-muted, #94a3b8);
font-size: 14px;
padding: 8px;
}
`;

View file

@ -27,7 +27,7 @@ const infoTurns = document.getElementById('info-turns') as HTMLElement;
const infoReason = document.getElementById('info-reason') as HTMLElement;
// Initialize viewer
let viewer = new ReplayViewer(canvas, { cellSize: 10 });
let viewer = new ReplayViewer(canvas, { cellSize: 16 });
// Enable controls when replay is loaded
function enableControls(): void {
@ -182,6 +182,8 @@ cellSizeSelect.addEventListener('change', () => {
const replay = viewer.getReplay();
if (replay) {
viewer = new ReplayViewer(canvas, { cellSize: size });
viewer.onTurnChange = () => { updateUI(); updateEventLog(); };
viewer.onPlayStateChange = (playing) => { playBtn.textContent = playing ? 'Pause' : 'Play'; };
loadReplay(replay);
}
});
@ -233,3 +235,17 @@ document.addEventListener('keydown', (e) => {
});
console.log('AI Code Battle Replay Viewer initialized');
// Auto-load demo replay
(async () => {
try {
const response = await fetch('/data/demo-replay-v2.json');
if (response.ok) {
const replay = await response.json() as Replay;
loadReplay(replay);
urlInput.value = '/data/demo-replay-v2.json';
}
} catch (e) {
// silently fail - user can load manually
}
})();

View file

@ -0,0 +1,394 @@
// Predictions Page - Prediction leaderboard and stats
import type { BotProfile, PredictorStats } from '../api-types';
import { fetchPredictionsLeaderboard } from '../api-types';
const PAGES_BASE = '';
export async function renderPredictionsPage(): Promise<void> {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="predictions-page">
<h1 class="page-title">Prediction Leaderboard</h1>
<p class="page-subtitle">Top predictors and their accuracy stats</p>
<div class="how-it-works">
<h2>How It Works</h2>
<p>Predict the winner of upcoming matches before they start. The more accurate your predictions, the higher you climb the leaderboard.</p>
<div class="rules-grid">
<div class="rule-card">
<span class="rule-icon">1</span>
<div class="rule-text">
<h3>Make a Pick</h3>
<p>Choose which bot you think will win a match</p>
</div>
</div>
<div class="rule-card">
<span class="rule-icon">2</span>
<div class="rule-text">
<h3>Wait for Result</h3>
<p>After the match completes, predictions are resolved</p>
</div>
</div>
<div class="rule-card">
<span class="rule-icon">3</span>
<div class="rule-text">
<h3>Climb the Ranks</h3>
<p>Correct predictions increase your streak and ranking</p>
</div>
</div>
</div>
</div>
<div class="leaderboard-section">
<h2>Top Predictors</h2>
<div id="leaderboard-container">
<div class="loading">Loading leaderboard...</div>
</div>
</div>
<div class="stats-section">
<h2>Your Stats</h2>
<div id="your-stats" style="display: none;">
<div class="your-stats-card">
<div class="stat-row">
<span class="stat-label">Predictions Made</span>
<span class="stat-value" id="stat-total">-</span>
</div>
<div class="stat-row">
<span class="stat-label">Accuracy</span>
<span class="stat-value" id="stat-accuracy">-</span>
</div>
<div class="stat-row">
<span class="stat-label">Current Streak</span>
<span class="stat-value" id="stat-streak">-</span>
</div>
<div class="stat-row">
<span class="stat-label">Best Streak</span>
<span class="stat-value" id="stat-best-streak">-</span>
</div>
</div>
</div>
<div id="stats-login-prompt">
<p>Log in to track your predictions</p>
<button class="btn primary" id="login-btn">Connect</button>
</div>
</div>
</div>
<style>
.predictions-page {
max-width: 1000px;
margin: 0 auto;
}
.page-title {
margin-bottom: 8px;
}
.page-subtitle {
color: var(--text-muted);
margin-bottom: 32px;
}
.how-it-works {
background-color: var(--bg-secondary);
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
}
.how-it-works h2 {
margin-bottom: 16px;
}
.how-it-works p {
color: var(--text-muted);
margin-bottom: 20px;
}
.rules-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.rule-card {
display: flex;
align-items: flex-start;
gap: 12px;
background-color: var(--bg-tertiary);
border-radius: 8px;
padding: 16px;
}
.rule-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background-color: var(--accent);
color: white;
border-radius: 50%;
font-weight: 700;
flex-shrink: 0;
}
.rule-text h3 {
font-size: 0.875rem;
margin-bottom: 4px;
}
.rule-text p {
font-size: 0.75rem;
color: var(--text-muted);
margin: 0;
}
.leaderboard-section {
margin-bottom: 32px;
}
.leaderboard-section h2 {
margin-bottom: 16px;
}
.predictions-table {
width: 100%;
border-collapse: collapse;
background-color: var(--bg-secondary);
border-radius: 8px;
overflow: hidden;
}
.predictions-table th,
.predictions-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--bg-tertiary);
}
.predictions-table th {
background-color: var(--bg-tertiary);
color: var(--text-muted);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.predictions-table tr:hover {
background-color: var(--bg-tertiary);
}
.predictions-table .rank {
font-weight: 700;
color: var(--text-muted);
min-width: 40px;
}
.predictions-table tr.rank-1 .rank { color: #fbbf24; }
.predictions-table tr.rank-2 .rank { color: #94a3b8; }
.predictions-table tr.rank-3 .rank { color: #cd7f32; }
.predictor-name {
color: var(--text-primary);
font-weight: 500;
}
.accuracy-bar {
display: flex;
align-items: center;
gap: 8px;
}
.accuracy-fill {
height: 8px;
background-color: var(--accent);
border-radius: 4px;
transition: width 0.3s;
}
.accuracy-text {
font-size: 0.875rem;
color: var(--text-secondary);
min-width: 50px;
}
.streak-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.streak-badge.positive {
background-color: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.streak-badge.negative {
background-color: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.streak-badge.neutral {
background-color: rgba(107, 114, 128, 0.2);
color: #94a3b8;
}
.loading {
color: var(--text-muted);
text-align: center;
padding: 40px;
}
.empty-message {
color: var(--text-muted);
text-align: center;
padding: 40px;
}
.stats-section h2 {
margin-bottom: 16px;
}
.your-stats-card {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 20px;
}
.stat-row {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--bg-tertiary);
}
.stat-row:last-child {
border-bottom: none;
}
.stat-label {
color: var(--text-muted);
}
.stat-value {
color: var(--text-primary);
font-weight: 600;
}
#stats-login-prompt {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 24px;
text-align: center;
}
#stats-login-prompt p {
color: var(--text-muted);
margin-bottom: 16px;
}
.updated-at {
color: var(--text-muted);
font-size: 0.75rem;
margin-top: 16px;
}
</style>
`;
// Load leaderboard
await loadLeaderboard();
}
// fetch bot names for leaderboard display
async function loadLeaderboard(): Promise<void> {
const container = document.getElementById('leaderboard-container');
if (!container) return;
try {
const data = await fetchPredictionsLeaderboard();
if (data.entries.length === 0) {
container.innerHTML = '<div class="empty-message">No predictions have been made yet</div>';
return;
}
// Fetch bot names for predictor IDs
const botNames = await fetchBotNames(data.entries.map((e: PredictorStats) => e.predictor_id));
container.innerHTML = `
<table class="predictions-table">
<thead>
<tr>
<th>Rank</th>
<th>Predictor</th>
<th>Correct</th>
<th>Incorrect</th>
<th>Accuracy</th>
<th>Streak</th>
</tr>
</thead>
<tbody>
${data.entries.map((entry: PredictorStats, idx: number) => {
const total = entry.correct + entry.incorrect;
const accuracy = total > 0 ? Math.round((entry.correct / total) * 100) : 0;
const streakClass = entry.streak > 0 ? 'positive' : entry.streak < 0 ? 'negative' : 'neutral';
const botName = botNames.get(entry.predictor_id) || entry.predictor_id;
return `
<tr class="rank-${idx + 1}">
<td class="rank">#${idx + 1}</td>
<td class="predictor-name">${botName}</td>
<td>${entry.correct}</td>
<td>${entry.incorrect}</td>
<td>
<div class="accuracy-bar">
<div class="accuracy-fill" style="width: ${accuracy}%"></div>
<span class="accuracy-text">${accuracy}%</span>
</div>
</td>
<td>
<span class="streak-badge ${streakClass}">
${entry.streak > 0 ? '+' : ''}${entry.streak}
</span>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
<p class="updated-at">Updated: ${new Date(data.updated_at).toLocaleString()}</p>
`;
} catch (err) {
console.error('Failed to load predictions leaderboard:', err);
container.innerHTML = '<div class="empty-message">Failed to load leaderboard</div>';
}
}
async function fetchBotNames(botIds: string[]): Promise<Map<string, string>> {
const names = new Map<string, string>();
const uniqueIds = [...new Set(botIds)];
await Promise.all(uniqueIds.map(async id => {
try {
const response = await fetch(`${PAGES_BASE}/data/bots/${id}.json`);
if (response.ok) {
const bot: BotProfile = await response.json();
names.set(id, bot.name);
}
} catch {
// Ignore errors, will use ID as fallback
}
}));
return names;
}

442
web/src/pages/seasons.ts Normal file
View file

@ -0,0 +1,442 @@
// Seasons Page - Browse seasonal competitions
import type { Season, SeasonIndex, SeasonSnapshot } from '../types';
const PAGES_BASE = '';
export async function renderSeasonsPage(): Promise<void> {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="seasons-page">
<h1 class="page-title">Seasons</h1>
<p class="page-subtitle">Seasonal competition history and archives</p>
<div class="active-season" id="active-season" style="display: none;">
<h2>Current Season</h2>
<div id="active-season-content"></div>
</div>
<div class="seasons-list-section">
<h2>All Seasons</h2>
<div class="seasons-list" id="seasons-list">
<div class="loading">Loading seasons...</div>
</div>
</div>
<div class="season-detail" id="season-detail" style="display: none;">
<button class="back-btn" id="back-btn"> Back to Seasons</button>
<div id="season-detail-content"></div>
</div>
</div>
<style>
.seasons-page {
max-width: 1000px;
margin: 0 auto;
}
.page-title {
margin-bottom: 8px;
}
.page-subtitle {
color: var(--text-muted);
margin-bottom: 24px;
}
.active-season {
background-color: var(--bg-secondary);
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
border: 2px solid var(--accent);
}
.active-season h2 {
color: var(--accent);
margin-bottom: 16px;
}
.season-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.season-info h3 {
margin-bottom: 4px;
}
.season-theme {
color: var(--text-muted);
font-size: 0.875rem;
}
.season-dates {
color: var(--text-muted);
font-size: 0.75rem;
text-align: right;
}
.season-progress {
margin-top: 16px;
}
.progress-bar {
height: 8px;
background-color: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: var(--accent);
transition: width 0.3s;
}
.progress-label {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 4px;
}
.seasons-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.season-card {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.season-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.season-card h3 {
margin-bottom: 8px;
}
.season-card .champion {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding: 8px;
background-color: rgba(255, 215, 0, 0.1);
border-radius: 4px;
}
.champion-crown {
color: gold;
font-size: 1.25rem;
}
.champion-name {
font-weight: 600;
color: var(--text-primary);
}
.season-card .meta {
display: flex;
justify-content: space-between;
color: var(--text-muted);
font-size: 0.75rem;
}
.status-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.active { background-color: #22c55e; color: white; }
.status-badge.completed { background-color: #3b82f6; color: white; }
.status-badge.upcoming { background-color: #6b7280; color: white; }
.loading {
color: var(--text-muted);
text-align: center;
padding: 40px;
grid-column: 1 / -1;
}
.back-btn {
background-color: transparent;
color: var(--accent);
border: none;
padding: 8px 0;
cursor: pointer;
font-size: 14px;
margin-bottom: 16px;
}
.back-btn:hover {
text-decoration: underline;
}
.leaderboard-table {
width: 100%;
border-collapse: collapse;
}
.leaderboard-table th,
.leaderboard-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.leaderboard-table th {
color: var(--text-muted);
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
}
.leaderboard-table .rank-1 {
color: gold;
font-weight: 700;
}
.leaderboard-table .rank-2 {
color: silver;
font-weight: 600;
}
.leaderboard-table .rank-3 {
color: #cd7f32;
font-weight: 500;
}
.empty-message {
color: var(--text-muted);
text-align: center;
padding: 40px;
grid-column: 1 / -1;
}
.season-rules {
margin-top: 24px;
padding: 16px;
background-color: var(--bg-tertiary);
border-radius: 8px;
}
.season-rules h4 {
margin-bottom: 12px;
color: var(--text-muted);
}
.season-rules ul {
margin-left: 20px;
color: var(--text-secondary);
}
.season-rules li {
margin-bottom: 4px;
}
</style>
`;
// Load seasons data
await loadSeasons();
}
async function loadSeasons(): Promise<void> {
const activeSeasonContainer = document.getElementById('active-season');
const activeSeasonContent = document.getElementById('active-season-content');
const list = document.getElementById('seasons-list');
if (!list) return;
try {
const response = await fetch(`${PAGES_BASE}/data/seasons/index.json`);
if (!response.ok) throw new Error('Failed to load seasons');
const index: SeasonIndex = await response.json();
// Show active season if present
if (index.active_season && activeSeasonContainer && activeSeasonContent) {
activeSeasonContainer.style.display = 'block';
activeSeasonContent.innerHTML = renderActiveSeason(index.active_season);
// Wire click handler
activeSeasonContent.querySelector('.season-card')?.addEventListener('click', () => {
showSeasonDetail(index.active_season!.id);
});
}
// Render all seasons
if (index.seasons.length === 0) {
list.innerHTML = '<div class="empty-message">No seasons available yet</div>';
return;
}
list.innerHTML = index.seasons.map((s: Season) => `
<div class="season-card" data-season-id="${s.id}">
<h3>${s.name}</h3>
${s.champion_name ? `
<div class="champion">
<span class="champion-crown">👑</span>
<span class="champion-name">${s.champion_name}</span>
</div>
` : ''}
<div class="meta">
<span class="status-badge ${s.status}">${s.status}</span>
<span>${s.total_matches} matches</span>
</div>
</div>
`).join('');
// Wire click handlers
list.querySelectorAll('.season-card').forEach(card => {
card.addEventListener('click', () => {
const seasonId = (card as HTMLElement).dataset.seasonId;
if (seasonId) showSeasonDetail(seasonId);
});
});
} catch (err) {
console.error('Failed to load seasons:', err);
list.innerHTML = '<div class="empty-message">Failed to load seasons. Please try again later.</div>';
}
}
function renderActiveSeason(season: Season): string {
const startDate = new Date(season.starts_at);
const now = new Date();
let progressPercent = 0;
if (season.ends_at) {
const endDate = new Date(season.ends_at);
const total = endDate.getTime() - startDate.getTime();
const elapsed = now.getTime() - startDate.getTime();
progressPercent = Math.min(100, Math.max(0, (elapsed / total) * 100));
}
return `
<div class="season-card" data-season-id="${season.id}">
<div class="season-header">
<div class="season-info">
<h3>${season.name}</h3>
<p class="season-theme">${season.theme}</p>
</div>
<div class="season-dates">
<span class="status-badge ${season.status}">${season.status}</span>
<div>Started: ${startDate.toLocaleDateString()}</div>
${season.ends_at ? `<div>Ends: ${new Date(season.ends_at).toLocaleDateString()}</div>` : ''}
</div>
</div>
<div class="season-progress">
<div class="progress-bar">
<div class="progress-fill" style="width: ${progressPercent}%"></div>
</div>
<div class="progress-label">
<span>${season.total_matches} matches played</span>
<span>${Math.round(progressPercent)}% complete</span>
</div>
</div>
</div>
`;
}
async function showSeasonDetail(seasonId: string): Promise<void> {
const listSection = document.querySelector('.seasons-list-section') as HTMLElement;
const activeSeason = document.getElementById('active-season');
const detail = document.getElementById('season-detail');
const detailContent = document.getElementById('season-detail-content');
const backBtn = document.getElementById('back-btn');
if (!detail || !detailContent) return;
try {
const response = await fetch(`${PAGES_BASE}/data/seasons/${seasonId}.json`);
if (!response.ok) throw new Error('Season not found');
const season: Season = await response.json();
detailContent.innerHTML = `
<div class="season-header" style="margin-bottom: 24px;">
<div class="season-info">
<h2>${season.name}</h2>
<p class="season-theme">${season.theme}</p>
</div>
<div class="season-dates">
<span class="status-badge ${season.status}">${season.status}</span>
<div>Started: ${new Date(season.starts_at).toLocaleDateString()}</div>
${season.ends_at ? `<div>Ended: ${new Date(season.ends_at).toLocaleDateString()}</div>` : ''}
</div>
</div>
${season.champion_name ? `
<div class="champion" style="justify-content: center; padding: 20px; margin-bottom: 24px;">
<span class="champion-crown" style="font-size: 2rem;">👑</span>
<div>
<div style="color: var(--text-muted); font-size: 0.75rem;">CHAMPION</div>
<span class="champion-name" style="font-size: 1.25rem;">${season.champion_name}</span>
</div>
</div>
` : ''}
${season.final_snapshot && season.final_snapshot.length > 0 ? `
<h3>Final Leaderboard</h3>
<table class="leaderboard-table">
<thead>
<tr>
<th>Rank</th>
<th>Bot</th>
<th>Rating</th>
<th>W/L</th>
</tr>
</thead>
<tbody>
${season.final_snapshot.map((entry: SeasonSnapshot) => `
<tr>
<td class="rank-${entry.rank}">#${entry.rank}</td>
<td>${entry.bot_name}</td>
<td>${Math.round(entry.rating)}</td>
<td>${entry.wins}/${entry.losses}</td>
</tr>
`).join('')}
</tbody>
</table>
` : '<p>No leaderboard data available yet.</p>'}
<div class="season-rules">
<h4>Rules Version: ${season.rules_version}</h4>
<ul>
<li>Standard 60x60 toroidal grid</li>
<li>500 turn limit</li>
<li>Glicko-2 rating system</li>
<li>Best-of-1 matches</li>
</ul>
</div>
`;
if (listSection) listSection.style.display = 'none';
if (activeSeason) activeSeason.style.display = 'none';
detail.style.display = 'block';
backBtn!.onclick = () => {
detail.style.display = 'none';
if (listSection) listSection.style.display = 'block';
if (activeSeason) activeSeason.style.display = 'block';
};
} catch (err) {
console.error('Failed to load season:', err);
alert('Failed to load season details');
}
}

440
web/src/pages/series.ts Normal file
View file

@ -0,0 +1,440 @@
// Series Page - Browse multi-game series between bots
import type { Series, SeriesIndex } from '../types';
import type { BotProfile } from '../api-types';
const PAGES_BASE = '';
export async function renderSeriesPage(): Promise<void> {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="series-page">
<h1 class="page-title">Series</h1>
<p class="page-subtitle">Best-of-N matchups between bots</p>
<div class="series-filters">
<select id="status-filter">
<option value="">All Status</option>
<option value="active">In Progress</option>
<option value="completed">Completed</option>
<option value="pending">Upcoming</option>
</select>
<select id="bot-filter">
<option value="">All Bots</option>
</select>
</div>
<div class="series-list" id="series-list">
<div class="loading">Loading series...</div>
</div>
<div class="series-detail" id="series-detail" style="display: none;">
<button class="back-btn" id="back-btn"> Back to Series</button>
<div id="series-detail-content"></div>
</div>
</div>
<style>
.series-page {
max-width: 1000px;
margin: 0 auto;
}
.page-title {
margin-bottom: 8px;
}
.page-subtitle {
color: var(--text-muted);
margin-bottom: 24px;
}
.series-filters {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.series-filters select {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 8px 12px;
border-radius: 6px;
font-size: 14px;
}
.series-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.series-card {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.series-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.series-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.series-matchup {
display: flex;
align-items: center;
gap: 16px;
}
.series-bot {
display: flex;
flex-direction: column;
align-items: center;
min-width: 100px;
}
.series-bot-name {
font-weight: 500;
color: var(--text-primary);
}
.series-bot-rating {
font-size: 0.75rem;
color: var(--text-muted);
}
.series-vs {
font-size: 0.875rem;
color: var(--text-muted);
font-weight: 600;
}
.series-score {
display: flex;
align-items: center;
gap: 8px;
font-size: 1.25rem;
font-weight: 600;
}
.score-winner {
color: #22c55e;
}
.score-loser {
color: var(--text-muted);
}
.series-meta {
display: flex;
justify-content: space-between;
color: var(--text-muted);
font-size: 0.75rem;
}
.status-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.active { background-color: #22c55e; color: white; }
.status-badge.completed { background-color: #3b82f6; color: white; }
.status-badge.pending { background-color: #6b7280; color: white; }
.series-games {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.game-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background-color: var(--bg-tertiary);
border-radius: 6px;
}
.game-number {
font-weight: 600;
color: var(--text-muted);
min-width: 30px;
}
.game-result {
flex: 1;
color: var(--text-primary);
}
.game-result.winner-1 { color: #3b82f6; }
.game-result.winner-2 { color: #ef4444; }
.watch-btn {
background-color: var(--accent);
color: white;
border: none;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.watch-btn:hover {
opacity: 0.9;
}
.spoiler-toggle {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.spoiler-toggle input {
width: 16px;
height: 16px;
}
.spoiler-hidden .series-score,
.spoiler-hidden .game-result {
filter: blur(4px);
cursor: pointer;
}
.loading {
color: var(--text-muted);
text-align: center;
padding: 40px;
}
.back-btn {
background-color: transparent;
color: var(--accent);
border: none;
padding: 8px 0;
cursor: pointer;
font-size: 14px;
margin-bottom: 16px;
}
.back-btn:hover {
text-decoration: underline;
}
.empty-message {
color: var(--text-muted);
text-align: center;
padding: 40px;
}
</style>
`;
// Load series data
await loadSeries();
// Setup spoiler toggle
const spoilerToggle = document.createElement('div');
spoilerToggle.className = 'spoiler-toggle';
spoilerToggle.innerHTML = `
<input type="checkbox" id="spoiler-toggle">
<label for="spoiler-toggle">Hide spoilers (scores/results)</label>
`;
const seriesList = document.getElementById('series-list');
seriesList?.parentElement?.insertBefore(spoilerToggle, seriesList);
document.getElementById('spoiler-toggle')?.addEventListener('change', (e) => {
const checked = (e.target as HTMLInputElement).checked;
document.querySelector('.series-list')?.classList.toggle('spoiler-hidden', checked);
});
}
async function loadSeries(): Promise<void> {
const list = document.getElementById('series-list');
const botFilter = document.getElementById('bot-filter') as HTMLSelectElement;
const statusFilter = document.getElementById('status-filter') as HTMLSelectElement;
if (!list) return;
try {
const response = await fetch(`${PAGES_BASE}/data/series/index.json`);
if (!response.ok) throw new Error('Failed to load series');
const index: SeriesIndex = await response.json();
if (index.series.length === 0) {
list.innerHTML = '<div class="empty-message">No series available yet</div>';
return;
}
// Populate bot filter
const bots = new Set<string>();
index.series.forEach((s: Series) => {
bots.add(s.bot1_id);
bots.add(s.bot2_id);
});
// Fetch bot names
const botNames = new Map<string, string>();
for (const botId of bots) {
try {
const botRes = await fetch(`${PAGES_BASE}/data/bots/${botId}.json`);
if (botRes.ok) {
const bot: BotProfile = await botRes.json();
botNames.set(botId, bot.name);
}
} catch {}
}
// Update filter options
bots.forEach(botId => {
const option = document.createElement('option');
option.value = botId;
option.textContent = botNames.get(botId) || botId;
botFilter.appendChild(option);
});
// Render series cards
renderSeriesList(index.series, list, botNames);
// Filter handlers
const applyFilters = () => {
const statusVal = statusFilter.value;
const botVal = botFilter.value;
const filtered = index.series.filter((s: Series) => {
if (statusVal && s.status !== statusVal) return false;
if (botVal && s.bot1_id !== botVal && s.bot2_id !== botVal) return false;
return true;
});
renderSeriesList(filtered, list, botNames);
};
statusFilter.addEventListener('change', applyFilters);
botFilter.addEventListener('change', applyFilters);
} catch (err) {
console.error('Failed to load series:', err);
list.innerHTML = '<div class="empty-message">Failed to load series. Please try again later.</div>';
}
}
function renderSeriesList(series: Series[], container: HTMLElement, _botNames: Map<string, string>): void {
container.innerHTML = series.map(s => `
<div class="series-card" data-series-id="${s.id}">
<div class="series-header">
<div class="series-matchup">
<div class="series-bot">
<span class="series-bot-name">${s.bot1_name}</span>
</div>
<span class="series-vs">vs</span>
<div class="series-bot">
<span class="series-bot-name">${s.bot2_name}</span>
</div>
</div>
<div class="series-score">
<span class="${s.bot1_wins > s.bot2_wins ? 'score-winner' : 'score-loser'}">${s.bot1_wins}</span>
<span>-</span>
<span class="${s.bot2_wins > s.bot1_wins ? 'score-winner' : 'score-loser'}">${s.bot2_wins}</span>
</div>
</div>
<div class="series-meta">
<span class="status-badge ${s.status}">${s.status}</span>
<span>Best of ${s.best_of}</span>
<span>${s.completed_at ? new Date(s.completed_at).toLocaleDateString() : 'In progress'}</span>
</div>
</div>
`).join('');
// Wire click handlers
container.querySelectorAll('.series-card').forEach(card => {
card.addEventListener('click', () => {
const seriesId = (card as HTMLElement).dataset.seriesId;
if (seriesId) showSeriesDetail(seriesId);
});
});
}
async function showSeriesDetail(seriesId: string): Promise<void> {
const list = document.getElementById('series-list');
const detail = document.getElementById('series-detail');
const detailContent = document.getElementById('series-detail-content');
const backBtn = document.getElementById('back-btn');
if (!list || !detail || !detailContent) return;
try {
const response = await fetch(`${PAGES_BASE}/data/series/${seriesId}.json`);
if (!response.ok) throw new Error('Series not found');
const series: Series = await response.json();
detailContent.innerHTML = `
<div class="series-header" style="margin-bottom: 24px;">
<h2>${series.bot1_name} vs ${series.bot2_name}</h2>
<span class="status-badge ${series.status}">${series.status}</span>
</div>
<div class="series-score" style="justify-content: center; margin-bottom: 24px; font-size: 2rem;">
<span class="${series.bot1_wins > series.bot2_wins ? 'score-winner' : 'score-loser'}">${series.bot1_wins}</span>
<span>-</span>
<span class="${series.bot2_wins > series.bot1_wins ? 'score-winner' : 'score-loser'}">${series.bot2_wins}</span>
</div>
<h3>Games</h3>
<div class="series-games">
${series.games.map(g => {
const winnerClass = g.winner_slot === 0 ? 'winner-1' : g.winner_slot === 1 ? 'winner-2' : '';
const winnerName = g.winner_slot === 0 ? series.bot1_name : g.winner_slot === 1 ? series.bot2_name : 'Draw';
return `
<div class="game-row">
<span class="game-number">Game ${g.game_number}</span>
<span class="game-result ${winnerClass}">
${g.completed_at ? (g.winner_id ? `Winner: ${winnerName}` : 'Draw') : 'Not played'}
${g.turns ? `(${g.turns} turns)` : ''}
</span>
${g.match_id ? `<button class="watch-btn" data-match-id="${g.match_id}">Watch</button>` : ''}
</div>
`;
}).join('')}
</div>
`;
// Wire watch buttons
detailContent.querySelectorAll('.watch-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const matchId = (btn as HTMLElement).dataset.matchId;
if (matchId) {
window.location.hash = `/replay?match=${matchId}`;
}
});
});
list.style.display = 'none';
detail.style.display = 'block';
backBtn!.onclick = () => {
detail.style.display = 'none';
list.style.display = 'flex';
};
} catch (err) {
console.error('Failed to load series:', err);
alert('Failed to load series details');
}
}

View file

@ -0,0 +1,412 @@
// Replay Commentary Module
// Provides AI-generated commentary for featured matches based on critical moments.
import type { Replay } from './types';
import { WinProbabilityEngine, type CriticalMoment, type WinProbPoint } from './win-probability';
import type { Replay as EngineReplay } from './engine';
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
export interface CommentarySegment {
turn: number;
type: 'opening' | 'critical' | 'milestone' | 'closing';
headline: string;
detail: string;
playerFocus?: number; // Which player is the focus (0 or 1)
}
export interface ReplayCommentary {
matchId: string;
summary: string;
segments: CommentarySegment[];
highlights: MatchHighlight[];
winnerNarrative: string;
}
export interface MatchHighlight {
turn: number;
description: string;
importance: 'high' | 'medium' | 'low';
}
// ─────────────────────────────────────────────────────────────────────────────
// Commentary Generator
// ─────────────────────────────────────────────────────────────────────────────
export class CommentaryGenerator {
private replay: Replay;
private playerName0: string;
private playerName1: string;
constructor(replay: Replay) {
this.replay = replay;
this.playerName0 = replay.players[0]?.name ?? 'Player 0';
this.playerName1 = replay.players[1]?.name ?? 'Player 1';
}
async generateCommentary(simulations = 30): Promise<ReplayCommentary> {
// Compute win probabilities and critical moments
// Cast to EngineReplay - the shape is compatible for our purposes
const wpEngine = new WinProbabilityEngine(this.replay as unknown as EngineReplay);
await wpEngine.computeAll(simulations, 5);
const sparkline = wpEngine.getSparkline();
const criticalMoments = wpEngine.getCriticalMoments();
const segments: CommentarySegment[] = [];
const highlights: MatchHighlight[] = [];
// Opening commentary
segments.push(this.generateOpeningCommentary());
// Critical moment commentary
for (const cm of criticalMoments) {
const segment = this.generateCriticalMomentCommentary(cm);
segments.push(segment);
highlights.push({
turn: cm.turn,
description: cm.description,
importance: Math.abs(cm.deltaP0) > 0.25 ? 'high' : 'medium',
});
}
// Add milestone commentaries (every ~25% of match)
segments.push(...this.generateMilestoneCommentaries(sparkline));
// Closing commentary
segments.push(this.generateClosingCommentary());
// Sort segments by turn
segments.sort((a, b) => a.turn - b.turn);
// Generate summary
const summary = this.generateMatchSummary(sparkline, criticalMoments);
// Winner narrative
const winnerNarrative = this.generateWinnerNarrative();
return {
matchId: this.replay.match_id,
summary,
segments,
highlights,
winnerNarrative,
};
}
private generateOpeningCommentary(): CommentarySegment {
const openings = [
`${this.playerName0} and ${this.playerName1} square off on the grid. The opening moves will set the tone.`,
`A new contest begins. Both commanders position their forces for the battle ahead.`,
`The grid comes alive as ${this.playerName0} and ${this.playerName1} deploy their initial strategies.`,
`Two bots enter the arena. Who will claim dominance?`,
];
return {
turn: 0,
type: 'opening',
headline: 'Match Start',
detail: openings[Math.floor(Math.random() * openings.length)],
};
}
private generateCriticalMomentCommentary(cm: CriticalMoment): CommentarySegment {
const playerAhead = cm.deltaP0 > 0 ? this.playerName0 : this.playerName1;
const templates = this.getCommentaryTemplates(cm.type);
const template = templates[Math.floor(Math.random() * templates.length)];
const headline = this.generateHeadline(cm);
const detail = template
.replace(/\{winner\}/g, playerAhead)
.replace(/\{loser\}/g, cm.deltaP0 > 0 ? this.playerName1 : this.playerName0)
.replace(/\{delta\}/g, Math.abs(cm.deltaP0 * 100).toFixed(0));
return {
turn: cm.turn,
type: 'critical',
headline,
detail,
playerFocus: cm.deltaP0 > 0 ? 0 : 1,
};
}
private getCommentaryTemplates(type: CriticalMoment['type']): string[] {
switch (type) {
case 'capture':
return [
'{winner} seizes a core! A {delta}% swing in win probability.',
'Core captured! {winner} claims vital territory. The odds shift {delta}%',
'Strategic masterstroke from {winner} — a core falls, shifting momentum by {delta}%.',
];
case 'kill':
return [
'Carnage on the grid! {winner} eliminates multiple units. {delta}% swing.',
'{loser} suffers heavy losses as {winner} strikes decisively. {delta}% probability shift.',
];
case 'energy':
return [
'{winner} secures crucial energy resources. {delta}% shift in their favor.',
'Resource advantage builds for {winner}. The tide turns {delta}%.',
];
default:
return [
'A pivotal moment! {winner} {delta}% swing in win probability.',
'The momentum shifts — {winner} pulls ahead with a {delta}% advantage.',
];
}
}
private generateHeadline(cm: CriticalMoment): string {
switch (cm.type) {
case 'capture': return 'Core Captured!';
case 'kill': return 'Combat Clash!';
case 'energy': return 'Energy Secured';
default: return 'Momentum Shift';
}
}
private generateMilestoneCommentaries(sparkline: WinProbPoint[]): CommentarySegment[] {
const segments: CommentarySegment[] = [];
const totalTurns = this.replay.turns.length;
const quarters = [0.25, 0.5, 0.75];
for (const q of quarters) {
const turn = Math.floor(totalTurns * q);
if (turn < 10) continue;
const pt = sparkline.find(p => p.turn >= turn) ?? sparkline[sparkline.length - 1];
if (!pt) continue;
const leader = pt.p0WinProb > pt.p1WinProb ? this.playerName0 : this.playerName1;
const prob = Math.max(pt.p0WinProb, pt.p1WinProb);
let status: string;
if (prob > 0.75) {
status = `${leader} holds a commanding lead (${(prob * 100).toFixed(0)}% win probability)`;
} else if (prob > 0.55) {
status = `${leader} has a slight edge (${(prob * 100).toFixed(0)}% win probability)`;
} else {
status = `The match remains deadlocked at 50-50`;
}
segments.push({
turn,
type: 'milestone',
headline: `Turn ${turn}`,
detail: status,
});
}
return segments;
}
private generateClosingCommentary(): CommentarySegment {
const result = this.replay.result;
const winner = result.winner >= 0 ? (result.winner === 0 ? this.playerName0 : this.playerName1) : null;
const closings = winner
? [
`${winner} claims victory by ${result.reason}!`,
`The final blow lands — ${winner} wins by ${result.reason}.`,
`${result.reason} ends it! ${winner} stands triumphant.`,
]
: [
`The match ends in a draw! Neither bot could claim dominance.`,
`A stalemate! The grid remains contested as time runs out.`,
];
return {
turn: this.replay.turns.length - 1,
type: 'closing',
headline: 'Match Complete',
detail: closings[Math.floor(Math.random() * closings.length)],
};
}
private generateMatchSummary(sparkline: WinProbPoint[], moments: CriticalMoment[]): string {
const result = this.replay.result;
const winner = result.winner >= 0 ? (result.winner === 0 ? this.playerName0 : this.playerName1) : 'No one';
const turns = result.turns;
const criticalCount = moments.length;
const leadChanges = this.countLeadChanges(sparkline);
const biggestSwing = moments.length > 0
? Math.max(...moments.map(m => Math.abs(m.deltaP0)))
: 0;
let narrative: string;
if (criticalCount === 0) {
narrative = `A methodical ${turns}-turn match with ${winner} winning by ${result.reason}. No major momentum swings.`;
} else if (leadChanges > 3) {
narrative = `An action-packed ${turns}-turn battle! ${leadChanges} lead changes, ${criticalCount} critical moments, and ${winner} ultimately prevails by ${result.reason}.`;
} else if (biggestSwing > 0.3) {
narrative = `A match defined by a ${(biggestSwing * 100).toFixed(0)}% swing! ${winner} claims victory in ${turns} turns by ${result.reason}.`;
} else {
narrative = `${turns} turns of strategic play. ${winner} wins by ${result.reason} with ${criticalCount} pivotal moments.`;
}
return narrative;
}
private countLeadChanges(sparkline: WinProbPoint[]): number {
let changes = 0;
let prevLeader: number | null = null;
for (const pt of sparkline) {
const leader = pt.p0WinProb > pt.p1WinProb ? 0 : 1;
if (prevLeader !== null && leader !== prevLeader) {
changes++;
}
prevLeader = leader;
}
return changes;
}
private generateWinnerNarrative(): string {
const result = this.replay.result;
const winner = result.winner >= 0 ? (result.winner === 0 ? this.playerName0 : this.playerName1) : null;
if (!winner) {
return `An evenly matched contest ends in a draw. Both ${this.playerName0} and ${this.playerName1} proved worthy opponents.`;
}
const loser = winner === this.playerName0 ? this.playerName1 : this.playerName0;
const scoreDiff = Math.abs(result.scores[0] - result.scores[1]);
if (result.reason === 'elimination') {
return `${winner} achieves total dominance, eliminating ${loser}'s forces from the grid!`;
} else if (result.reason === 'dominance') {
return `${winner} establishes overwhelming control, forcing the match to end by dominance.`;
} else if (scoreDiff > 20) {
return `${winner} crushes the competition with a commanding ${scoreDiff}-point lead.`;
} else if (scoreDiff > 5) {
return `${winner} secures a solid victory with a ${scoreDiff}-point advantage.`;
} else {
return `${winner} edges out ${loser} in a nail-biting finish!`;
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Commentary Renderer
// ─────────────────────────────────────────────────────────────────────────────
export function renderCommentaryPanel(commentary: ReplayCommentary): string {
return `
<div class="commentary-panel">
<div class="commentary-summary">${escapeHtml(commentary.summary)}</div>
<div class="commentary-timeline">
${commentary.segments.map(s => `
<div class="commentary-segment type-${s.type}" data-turn="${s.turn}">
<div class="segment-turn">Turn ${s.turn}</div>
<div class="segment-content">
<div class="segment-headline">${escapeHtml(s.headline)}</div>
<div class="segment-detail">${escapeHtml(s.detail)}</div>
</div>
</div>
`).join('')}
</div>
</div>
`;
}
export function renderHighlightMarkers(
highlights: MatchHighlight[],
totalTurns: number,
): string {
return highlights.map(h => {
const pct = (h.turn / totalTurns) * 100;
const color = h.importance === 'high' ? '#ef4444' : h.importance === 'medium' ? '#f59e0b' : '#22c55e';
return `<div class="highlight-marker" style="left:${pct.toFixed(1)}%;background:${color}" title="${escapeHtml(h.description)}"></div>`;
}).join('');
}
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ─────────────────────────────────────────────────────────────────────────────
// Quick Commentary (No Monte Carlo - fast)
// ─────────────────────────────────────────────────────────────────────────────
export function quickCommentary(replay: Replay): ReplayCommentary {
const segments: CommentarySegment[] = [];
const highlights: MatchHighlight[] = [];
const playerName0 = replay.players[0]?.name ?? 'Player 0';
const playerName1 = replay.players[1]?.name ?? 'Player 1';
// Opening
segments.push({
turn: 0,
type: 'opening',
headline: 'Match Start',
detail: `${playerName0} and ${playerName1} begin their battle.`,
});
// Scan for events
for (const turn of replay.turns) {
if (!turn.events) continue;
for (const event of turn.events) {
if (event.type === 'core_captured') {
const details = event.details as { new_owner?: number };
const capturer = details?.new_owner === 0 ? playerName0 : playerName1;
segments.push({
turn: turn.turn,
type: 'critical',
headline: 'Core Captured!',
detail: `${capturer} claims an enemy core.`,
playerFocus: details?.new_owner,
});
highlights.push({
turn: turn.turn,
description: 'Core captured',
importance: 'high',
});
}
// Check for mass kills
const deaths = turn.events.filter(e => e.type === 'bot_died' || e.type === 'combat_death').length;
if (deaths >= 3) {
segments.push({
turn: turn.turn,
type: 'critical',
headline: 'Major Combat!',
detail: `${deaths} bots eliminated in a single turn.`,
});
highlights.push({
turn: turn.turn,
description: `${deaths} bots killed`,
importance: 'medium',
});
}
}
}
// Closing
const winner = replay.result.winner >= 0
? (replay.result.winner === 0 ? playerName0 : playerName1)
: null;
segments.push({
turn: replay.turns.length - 1,
type: 'closing',
headline: 'Match Complete',
detail: winner
? `${winner} wins by ${replay.result.reason}!`
: 'The match ends in a draw.',
});
return {
matchId: replay.match_id,
summary: `${replay.result.turns} turns. ${winner ? winner + ' wins by ' + replay.result.reason : 'Draw'}.`,
segments: segments.sort((a, b) => a.turn - b.turn),
highlights,
winnerNarrative: winner
? `${winner} emerges victorious!`
: 'A hard-fought draw.',
};
}

View file

@ -1,4 +1,116 @@
import type { Replay, ReplayTurn, Position, ReplayBot, GameEvent } from './types';
import type { Replay, ReplayTurn, Position, ReplayBot, GameEvent, DebugInfo, ViewMode } from './types';
// Win probability point for sparkline
export interface WinProbPoint {
turn: number;
p0WinProb: number;
p1WinProb: number;
drawProb?: number;
}
// Render win probability sparkline to canvas
export function renderWinProbSparkline(
ctx: CanvasRenderingContext2D,
points: WinProbPoint[],
currentTurn: number,
options: {
width: number;
height: number;
color0?: string;
color1?: string;
},
): void {
const { width, height, color0 = '#3b82f6', color1 = '#ef4444' } = options;
const padding = { top: 8, bottom: 8, left: 4, right: 4 };
const chartW = width - padding.left - padding.right;
const chartH = height - padding.top - padding.bottom;
if (points.length < 2) {
ctx.fillStyle = '#475569';
ctx.fillRect(0, 0, width, height);
return;
}
// Clear
ctx.fillStyle = '#1e293b';
ctx.fillRect(0, 0, width, height);
const maxTurn = points[points.length - 1].turn;
const x = (turn: number) => padding.left + (turn / maxTurn) * chartW;
const y = (prob: number) => padding.top + chartH * (1 - prob);
// 50% baseline
const midY = y(0.5);
ctx.strokeStyle = '#475569';
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(padding.left, midY);
ctx.lineTo(width - padding.right, midY);
ctx.stroke();
ctx.setLineDash([]);
// P0 area fill
ctx.beginPath();
ctx.moveTo(padding.left, y(0.5));
for (const pt of points) {
ctx.lineTo(x(pt.turn), y(pt.p0WinProb));
}
ctx.lineTo(width - padding.right, y(0.5));
ctx.closePath();
const grad = ctx.createLinearGradient(0, padding.top, 0, height - padding.bottom);
grad.addColorStop(0, color0 + '44');
grad.addColorStop(0.5, 'transparent');
grad.addColorStop(1, color1 + '44');
ctx.fillStyle = grad;
ctx.fill();
// P0 line
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
const pt = points[i];
if (i === 0) ctx.moveTo(x(pt.turn), y(pt.p0WinProb));
else ctx.lineTo(x(pt.turn), y(pt.p0WinProb));
}
ctx.strokeStyle = color0;
ctx.lineWidth = 2;
ctx.stroke();
// P1 line (dashed)
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
const pt = points[i];
if (i === 0) ctx.moveTo(x(pt.turn), y(pt.p1WinProb));
else ctx.lineTo(x(pt.turn), y(pt.p1WinProb));
}
ctx.strokeStyle = color1;
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.stroke();
ctx.setLineDash([]);
// Current turn marker
const curX = x(currentTurn);
ctx.strokeStyle = '#f8fafc';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(curX, padding.top);
ctx.lineTo(curX, height - padding.bottom);
ctx.stroke();
// Current probability dot
const curPt = points.find(p => p.turn >= currentTurn) ?? points[points.length - 1];
if (curPt) {
ctx.beginPath();
ctx.arc(curX, y(curPt.p0WinProb), 4, 0, Math.PI * 2);
ctx.fillStyle = curPt.p0WinProb > 0.5 ? color0 : color1;
ctx.fill();
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.stroke();
}
}
// ── Accessibility: Paul Tol's color-blind safe palette ──────────────────────────
// These colors are designed to be distinguishable for all color vision deficiencies
@ -30,17 +142,19 @@ const DEFAULT_PLAYER_COLORS = [
'#f59e0b', // Amber (player 3)
'#8b5cf6', // Purple (player 4)
'#06b6d4', // Cyan (player 5)
'#ec4899', // Pink (player 6)
'#f97316', // Orange (player 7)
];
// Shape types for each player (0-5) - allows shape + color identification
type PlayerShape = 'circle' | 'square' | 'triangle' | 'diamond' | 'pentagon' | 'hexagon';
const PLAYER_SHAPES: PlayerShape[] = ['circle', 'square', 'triangle', 'diamond', 'pentagon', 'hexagon'];
// Shape types for each player (0-7) - allows shape + color identification
type PlayerShape = 'circle' | 'square' | 'triangle' | 'diamond' | 'pentagon' | 'hexagon' | 'star' | 'cross';
const PLAYER_SHAPES: PlayerShape[] = ['circle', 'square', 'triangle', 'diamond', 'pentagon', 'hexagon', 'star', 'cross'];
const NEUTRAL_COLOR = '#6b7280'; // Gray
const WALL_COLOR = '#1f2937'; // Dark gray
const WALL_COLOR = '#4b5563'; // Medium gray - clearly distinct from background
const ENERGY_COLOR = '#fbbf24'; // Yellow
const BACKGROUND_COLOR = '#111827'; // Very dark gray
const GRID_COLOR = '#374151'; // Medium gray
const BACKGROUND_COLOR = '#0f172a'; // Dark navy - open tiles
const GRID_COLOR = '#1e293b'; // Subtle grid lines
// High contrast versions
const HIGH_CONTRAST_NEUTRAL = '#888888';
@ -59,6 +173,9 @@ export interface ViewerOptions {
highContrast?: boolean; // High contrast mode
showShapes?: boolean; // Draw different shapes per player (default: true)
reducedMotion?: boolean; // Skip animations (auto-detected from prefers-reduced-motion)
// View modes
viewMode?: ViewMode;
showDebug?: boolean; // Show debug telemetry overlay
}
// Accessibility mode configuration
@ -91,6 +208,8 @@ export class ReplayViewer {
private fogOfWarPlayer: number | null;
private animationSpeed: number;
private accessibility: AccessibilitySettings;
private viewMode: ViewMode;
private showDebug: boolean;
private screenReaderRegion: HTMLElement | null = null;
// Event callbacks
@ -118,6 +237,10 @@ export class ReplayViewer {
(options.reducedMotion ?? DEFAULT_ACCESSIBILITY.reducedMotion),
};
// Initialize view mode
this.viewMode = options.viewMode ?? 'standard';
this.showDebug = options.showDebug ?? false;
// Create screen reader region for announcements
this.createScreenReaderRegion();
@ -159,7 +282,9 @@ export class ReplayViewer {
if (!this.replay) return;
const { rows, cols } = this.replay.map;
this.canvas.width = cols * this.cellSize;
this.canvas.height = rows * this.cellSize;
// Extra space below map for score overlay (not overlapping the playfield)
const overlayHeight = 8 * 2 + 20 * (this.replay?.players?.length ?? 2) + 8;
this.canvas.height = rows * this.cellSize + overlayHeight;
}
private posKey(pos: Position): string {
@ -238,6 +363,26 @@ export class ReplayViewer {
return { ...this.accessibility };
}
// ── View Mode Controls ─────────────────────────────────────────────────────
setViewMode(mode: ViewMode): void {
this.viewMode = mode;
this.render();
}
getViewMode(): ViewMode {
return this.viewMode;
}
setShowDebug(show: boolean): void {
this.showDebug = show;
this.render();
}
getShowDebug(): boolean {
return this.showDebug;
}
// Get the active color palette based on accessibility settings
private getPlayerColors(): string[] {
if (this.accessibility.highContrast) {
@ -410,8 +555,7 @@ export class ReplayViewer {
private render(): void {
if (!this.replay) return;
const { ctx, cellSize, canvas } = this;
const { rows, cols } = this.replay.map;
const { ctx } = this;
const colors = this.getPlayerColors();
const bgColor = this.getBackgroundColor();
const gridColor = this.getGridColor();
@ -421,10 +565,64 @@ export class ReplayViewer {
// Clear canvas
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Get current turn data
const turnData = this.replay.turns[this.currentTurn];
if (!turnData) return;
// Determine visibility for fog of war
const visible = this.fogOfWarPlayer !== null
? this.computeVisibility(turnData, this.fogOfWarPlayer)
: null;
// Render based on view mode
switch (this.viewMode) {
case 'dots':
this.renderDotsView(turnData, visible, colors, neutralColor, energyColor);
break;
case 'influence':
this.renderInfluenceView(turnData, visible, colors, neutralColor, energyColor, wallColor);
break;
case 'voronoi':
this.renderVoronoiView(turnData, visible, colors, neutralColor, energyColor, wallColor);
break;
case 'standard':
default:
this.renderStandardView(turnData, visible, colors, neutralColor, energyColor, wallColor, gridColor);
break;
}
// Draw debug telemetry overlay if enabled
if (this.showDebug && turnData.debug) {
this.renderDebugOverlay(turnData.debug, colors);
}
// Draw score overlay
this.drawScoreOverlay(turnData, colors);
// Announce turn to screen reader if reduced motion is preferred
if (this.accessibility.reducedMotion) {
const events = turnData.events ?? [];
this.announceToScreenReader(this.generateTurnDescription(events));
}
}
// Standard view with grid
private renderStandardView(
turnData: ReplayTurn,
visible: Set<string> | null,
colors: string[],
neutralColor: string,
energyColor: string,
wallColor: string,
gridColor: string
): void {
const { ctx, cellSize, showGrid, replay } = this;
const { rows, cols } = replay!.map;
// Draw grid lines
if (this.showGrid) {
if (showGrid) {
ctx.strokeStyle = gridColor;
ctx.lineWidth = this.accessibility.highContrast ? 1 : 0.5;
for (let r = 0; r <= rows; r++) {
@ -441,17 +639,8 @@ export class ReplayViewer {
}
}
// Get current turn data
const turnData = this.replay.turns[this.currentTurn];
if (!turnData) return;
// Determine visibility for fog of war
const visible = this.fogOfWarPlayer !== null
? this.computeVisibility(turnData, this.fogOfWarPlayer)
: null;
// Draw walls (always visible)
for (const wall of this.replay.map.walls) {
// Draw walls
for (const wall of this.replay!.map.walls) {
this.drawCell(wall.row, wall.col, wallColor);
}
@ -468,7 +657,7 @@ export class ReplayViewer {
this.drawEnergy(energy.row, energy.col, energyColor);
}
// Draw bots with accessible shapes
// Draw bots
for (const bot of turnData.bots) {
if (!bot.alive) continue;
if (visible && !visible.has(this.posKey(bot.position))) continue;
@ -476,14 +665,362 @@ export class ReplayViewer {
this.drawBot(bot, color);
}
// Draw score overlay
this.drawScoreOverlay(turnData, colors);
// Draw combat effects from events this turn
this.drawCombatEffects(turnData, colors, visible);
}
// Announce turn to screen reader if reduced motion is preferred
if (this.accessibility.reducedMotion) {
const events = turnData.events ?? [];
this.announceToScreenReader(this.generateTurnDescription(events));
private drawCombatEffects(
turnData: ReplayTurn,
colors: string[],
visible: Set<string> | null
): void {
const { ctx, cellSize } = this;
const events = turnData.events ?? [];
// Collect death positions
const deaths: Array<{pos: Position; owner: number}> = [];
for (const event of events) {
if (event.type === 'bot_died') {
const d = event.details as any;
const rawPos = d.position ?? d.pos ?? d;
const pos: Position = {row: rawPos.Row ?? rawPos.row ?? 0, col: rawPos.Col ?? rawPos.col ?? 0};
if (pos.row === 0 && pos.col === 0 && !d.position && !d.pos) continue; // skip if no real position
deaths.push({pos, owner: d.owner ?? 0});
}
}
if (deaths.length === 0) return;
// Find living bots this turn to draw attack lines from nearby enemies
const livingBots = turnData.bots.filter(b => b.alive);
const attackRadius = Math.sqrt(this.replay?.config?.attack_radius2 ?? 5) * cellSize;
for (const death of deaths) {
if (visible && !visible.has(this.posKey(death.pos))) continue;
const dx = death.pos.col * cellSize + cellSize / 2;
const dy = death.pos.row * cellSize + cellSize / 2;
// Draw attack lines from nearby enemy bots to the death position
for (const attacker of livingBots) {
if (attacker.owner === death.owner) continue;
const ax = attacker.position.col * cellSize + cellSize / 2;
const ay = attacker.position.row * cellSize + cellSize / 2;
const dist = Math.hypot(ax - dx, ay - dy);
if (dist < attackRadius + cellSize * 3) {
ctx.strokeStyle = colors[attacker.owner];
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.4;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(dx, dy);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
}
}
// Draw red explosion flash behind the X
const flashRadius = cellSize * 0.8;
const gradient = ctx.createRadialGradient(dx, dy, 0, dx, dy, flashRadius);
gradient.addColorStop(0, 'rgba(239, 68, 68, 0.6)');
gradient.addColorStop(1, 'rgba(239, 68, 68, 0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(dx, dy, flashRadius, 0, Math.PI * 2);
ctx.fill();
// Draw death X marker
const xSize = cellSize * 0.35;
ctx.strokeStyle = '#fca5a5';
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(dx - xSize, dy - xSize);
ctx.lineTo(dx + xSize, dy + xSize);
ctx.moveTo(dx + xSize, dy - xSize);
ctx.lineTo(dx - xSize, dy + xSize);
ctx.stroke();
ctx.lineCap = 'butt';
}
}
// Dots view - minimal, just bot positions as dots
private renderDotsView(
turnData: ReplayTurn,
visible: Set<string> | null,
colors: string[],
_neutralColor: string,
_energyColor: string
): void {
const { ctx, cellSize } = this;
// Draw only bots as simple dots
for (const bot of turnData.bots) {
if (!bot.alive) continue;
if (visible && !visible.has(this.posKey(bot.position))) continue;
const x = bot.position.col * cellSize + cellSize / 2;
const y = bot.position.row * cellSize + cellSize / 2;
const radius = cellSize / 4;
ctx.fillStyle = colors[bot.owner];
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
}
// Influence view - shows territory influence gradient
private renderInfluenceView(
turnData: ReplayTurn,
visible: Set<string> | null,
colors: string[],
_neutralColor: string,
_energyColor: string,
_wallColor: string
): void {
const { ctx, cellSize, replay } = this;
const { rows, cols } = replay!.map;
// Compute influence map
const influence = this.computeInfluenceMap(turnData);
// Draw influence gradient
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const posKey = `${r},${c}`;
if (visible && !visible.has(posKey)) continue;
const inf = influence[r][c];
if (inf.owner >= 0) {
// Blend color based on influence strength
const baseColor = colors[inf.owner];
const alpha = Math.min(0.8, 0.2 + inf.strength * 0.6);
ctx.fillStyle = this.hexToRgba(baseColor, alpha);
ctx.fillRect(c * cellSize, r * cellSize, cellSize, cellSize);
}
}
}
// Draw bots on top
for (const bot of turnData.bots) {
if (!bot.alive) continue;
if (visible && !visible.has(this.posKey(bot.position))) continue;
const color = colors[bot.owner];
this.drawBot(bot, color);
}
}
// Voronoi view - shows Voronoi territories
private renderVoronoiView(
turnData: ReplayTurn,
visible: Set<string> | null,
colors: string[],
_neutralColor: string,
_energyColor: string,
_wallColor: string
): void {
const { ctx, cellSize, replay } = this;
const { rows, cols } = replay!.map;
// Compute Voronoi territories
const territories = this.computeVoronoiTerritories(turnData);
// Draw territories
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const posKey = `${r},${c}`;
if (visible && !visible.has(posKey)) continue;
const owner = territories[r][c];
if (owner >= 0) {
ctx.fillStyle = this.hexToRgba(colors[owner], 0.3);
ctx.fillRect(c * cellSize, r * cellSize, cellSize, cellSize);
}
}
}
// Draw bots
for (const bot of turnData.bots) {
if (!bot.alive) continue;
if (visible && !visible.has(this.posKey(bot.position))) continue;
const color = colors[bot.owner];
this.drawBot(bot, color);
}
}
// Compute influence map (distance-weighted bot influence)
private computeInfluenceMap(turnData: ReplayTurn): { owner: number; strength: number }[][] {
const { rows, cols } = this.replay!.map;
const influence: { owner: number; strength: number }[][] = [];
// Initialize grid
for (let r = 0; r < rows; r++) {
influence[r] = [];
for (let c = 0; c < cols; c++) {
influence[r][c] = { owner: -1, strength: 0 };
}
}
// For each cell, find the strongest influence
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
let maxInfluence = 0;
let bestOwner = -1;
for (const bot of turnData.bots) {
if (!bot.alive) continue;
const dist = this.toroidalDistance(r, c, bot.position.row, bot.position.col);
const inf = 1 / (1 + dist * 0.1);
if (inf > maxInfluence) {
maxInfluence = inf;
bestOwner = bot.owner;
}
}
influence[r][c] = { owner: bestOwner, strength: maxInfluence };
}
}
return influence;
}
// Compute Voronoi territories (nearest bot ownership)
private computeVoronoiTerritories(turnData: ReplayTurn): number[][] {
const { rows, cols } = this.replay!.map;
const territories: number[][] = [];
for (let r = 0; r < rows; r++) {
territories[r] = [];
for (let c = 0; c < cols; c++) {
let minDist = Infinity;
let owner = -1;
for (const bot of turnData.bots) {
if (!bot.alive) continue;
const dist = this.toroidalDistance(r, c, bot.position.row, bot.position.col);
if (dist < minDist) {
minDist = dist;
owner = bot.owner;
}
}
territories[r][c] = owner;
}
}
return territories;
}
// Toroidal distance calculation
private toroidalDistance(r1: number, c1: number, r2: number, c2: number): number {
const { rows, cols } = this.replay!.map;
const dr = Math.min(Math.abs(r1 - r2), rows - Math.abs(r1 - r2));
const dc = Math.min(Math.abs(c1 - c2), cols - Math.abs(c1 - c2));
return Math.sqrt(dr * dr + dc * dc);
}
// Convert hex color to rgba
private hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
// Render debug telemetry overlay
private renderDebugOverlay(debug: Record<number, DebugInfo>, colors: string[]): void {
const { ctx, cellSize } = this;
for (const [playerId, info] of Object.entries(debug)) {
const playerIdx = parseInt(playerId, 10);
const color = colors[playerIdx] || '#ffffff';
// Draw debug targets
if (info.targets) {
for (const target of info.targets) {
const x = target.position.col * cellSize + cellSize / 2;
const y = target.position.row * cellSize + cellSize / 2;
// Draw target marker
ctx.strokeStyle = target.color || color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(x, y, cellSize / 2, 0, Math.PI * 2);
ctx.stroke();
// Draw label if provided
if (target.label) {
ctx.fillStyle = color;
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText(target.label, x, y - cellSize / 2 - 4);
}
}
}
// Draw reasoning text
if (info.reasoning) {
const padding = 10;
const maxWidth = 200;
const lineHeight = 14;
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.fillRect(padding, this.canvas.height - 60 - padding, maxWidth + padding * 2, 50);
ctx.fillStyle = color;
ctx.font = '11px monospace';
ctx.textAlign = 'left';
const lines = this.wrapText(info.reasoning, maxWidth);
lines.forEach((line, i) => {
ctx.fillText(line, padding * 2, this.canvas.height - 60 + i * lineHeight);
});
}
}
}
// Wrap text to fit within max width
private wrapText(text: string, maxWidth: number): string[] {
const words = text.split(' ');
const lines: string[] = [];
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
// Approximate width (monospace, 11px)
const width = testLine.length * 6.6;
if (width > maxWidth && currentLine) {
lines.push(currentLine);
currentLine = word;
} else {
currentLine = testLine;
}
}
if (currentLine) {
lines.push(currentLine);
}
return lines.slice(0, 3); // Max 3 lines
}
// Get events for all turns (for timeline)
getAllEvents(): { turn: number; events: GameEvent[] }[] {
if (!this.replay) return [];
return this.replay.turns.map((turn, idx) => ({
turn: idx,
events: turn.events ?? []
}));
}
private computeVisibility(turnData: ReplayTurn, player: number): Set<string> {
@ -529,20 +1066,43 @@ export class ReplayViewer {
const { ctx, cellSize } = this;
const x = col * cellSize + cellSize / 2;
const y = row * cellSize + cellSize / 2;
const radius = (cellSize / 2) - 1;
const size = cellSize - 2;
ctx.fillStyle = color;
// Outer glow ring
if (active) {
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.globalAlpha = 0.3;
ctx.beginPath();
ctx.rect(x - size * 0.7, y - size * 0.7, size * 1.4, size * 1.4);
ctx.stroke();
ctx.globalAlpha = 1;
}
// Core body: filled square (distinct from circular bots)
ctx.fillStyle = active ? color : '#4b5563';
ctx.fillRect(x - size / 2, y - size / 2, size, size);
// Inner diamond cutout for visual distinction
ctx.fillStyle = active ? this.getBackgroundColor() : '#374151';
const inner = size * 0.3;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.moveTo(x, y - inner);
ctx.lineTo(x + inner, y);
ctx.lineTo(x, y + inner);
ctx.lineTo(x - inner, y);
ctx.closePath();
ctx.fill();
// Draw inactive marker
// Inactive: X overlay
if (!active) {
ctx.strokeStyle = this.getBackgroundColor();
ctx.lineWidth = this.accessibility.highContrast ? 3 : 2;
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x - radius / 2, y - radius / 2);
ctx.lineTo(x + radius / 2, y + radius / 2);
ctx.moveTo(x - size / 3, y - size / 3);
ctx.lineTo(x + size / 3, y + size / 3);
ctx.moveTo(x + size / 3, y - size / 3);
ctx.lineTo(x - size / 3, y + size / 3);
ctx.stroke();
}
}
@ -580,40 +1140,34 @@ export class ReplayViewer {
if (!this.replay) return;
const { ctx } = this;
const padding = 10;
const lineHeight = 24; // Increased for shape indicators
const padding = 8;
const lineHeight = 20;
const mapHeight = this.replay.map.rows * this.cellSize;
// Draw semi-transparent background
ctx.fillStyle = this.accessibility.highContrast ? 'rgba(0, 0, 0, 0.9)' : 'rgba(0, 0, 0, 0.7)';
// Draw below the map, not over it
const overlayY = mapHeight + 4;
const bgHeight = padding * 2 + lineHeight * this.replay.players.length;
ctx.fillRect(0, 0, 170, bgHeight);
const bgWidth = this.replay.map.cols * this.cellSize;
// Draw scores for each player
ctx.font = this.accessibility.highContrast ? 'bold 14px monospace' : '14px monospace';
ctx.fillStyle = '#1e293b';
ctx.fillRect(0, overlayY, bgWidth, bgHeight);
ctx.font = '13px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
this.replay.players.forEach((player, idx) => {
const score = turnData.scores[idx] ?? 0;
const energy = turnData.energy_held[idx] ?? 0;
const bots = turnData.bots.filter((b: any) => b.owner === idx).length;
const color = colors[idx];
const yOffset = padding + idx * lineHeight;
const yOffset = overlayY + padding + idx * lineHeight;
// Draw shape indicator for accessibility
const indicatorSize = 14;
const indicatorX = padding + indicatorSize / 2;
const indicatorY = yOffset + indicatorSize / 2 + 3;
ctx.fillStyle = color;
ctx.fillRect(padding, yOffset + 2, 12, 12);
if (this.accessibility.showShapes) {
this.drawPlayerShape(indicatorX, indicatorY, indicatorSize / 2 - 1, idx, color);
} else {
ctx.fillStyle = color;
ctx.fillRect(padding, yOffset + 3, indicatorSize, indicatorSize);
}
// Draw text with better contrast in high contrast mode
ctx.fillStyle = this.accessibility.highContrast ? '#ffffff' : '#e5e7eb';
ctx.fillText(`${player.name}: ${score} (E:${energy})`, padding + 22, yOffset + 4);
ctx.fillStyle = '#e5e7eb';
ctx.fillText(`${player.name} score:${score} bots:${bots} energy:${energy}`, padding + 18, yOffset + 2);
});
}
@ -634,4 +1188,53 @@ export class ReplayViewer {
if (!this.replay) return true;
return this.currentTurn >= this.replay.turns.length - 1;
}
// ── Win Probability Sparkline ─────────────────────────────────────────────────────
private winProbData: WinProbPoint[] | null = null;
private winProbCanvas: HTMLCanvasElement | null = null;
// Set win probability data for sparkline rendering
setWinProbabilityData(points: WinProbPoint[]): void {
this.winProbData = points;
if (this.winProbCanvas) {
this.renderWinProbSparkline();
}
}
// Get the win probability data
getWinProbabilityData(): WinProbPoint[] | null {
return this.winProbData;
}
// Create and attach a win probability sparkline canvas
createWinProbSparkline(container: HTMLElement, width?: number, height = 60): HTMLCanvasElement {
this.winProbCanvas = document.createElement('canvas');
this.winProbCanvas.width = width ?? container.clientWidth;
this.winProbCanvas.height = height;
this.winProbCanvas.className = 'win-prob-sparkline-canvas';
this.winProbCanvas.style.cssText = 'width:100%;height:' + height + 'px;border-radius:6px;';
container.appendChild(this.winProbCanvas);
if (this.winProbData) {
this.renderWinProbSparkline();
}
return this.winProbCanvas;
}
// Render the sparkline
private renderWinProbSparkline(): void {
if (!this.winProbCanvas || !this.winProbData || this.winProbData.length < 2) return;
const ctx = this.winProbCanvas.getContext('2d');
if (!ctx) return;
renderWinProbSparkline(ctx, this.winProbData, this.currentTurn, {
width: this.winProbCanvas.width,
height: this.winProbCanvas.height,
color0: this.accessibility.highContrast ? '#0000ff' : '#3b82f6',
color1: this.accessibility.highContrast ? '#ff0000' : '#ef4444',
});
}
}

View file

@ -69,6 +69,7 @@ export interface ReplayTurn {
scores: number[];
energy_held: number[];
events?: GameEvent[];
debug?: Record<number, DebugInfo>;
}
export interface Replay {
@ -120,3 +121,119 @@ export interface CollisionDeathDetails {
bot_ids: number[];
position: Position;
}
// Debug telemetry types
export interface DebugTarget {
position: Position;
label?: string;
color?: string;
}
export interface DebugInfo {
reasoning?: string;
targets?: DebugTarget[];
}
// Extended ReplayTurn with debug support
export interface ReplayTurnWithDebug extends ReplayTurn {
debug?: Record<number, DebugInfo>;
}
// View mode types for replay viewer
export type ViewMode = 'standard' | 'dots' | 'voronoi' | 'influence';
// Series types
export interface SeriesGame {
match_id: string;
game_number: number;
winner_id: string | null;
winner_slot: number | null;
turns: number | null;
completed_at: string | null;
}
export interface Series {
id: string;
bot1_id: string;
bot2_id: string;
bot1_name: string;
bot2_name: string;
best_of: number;
status: 'pending' | 'active' | 'completed';
bot1_wins: number;
bot2_wins: number;
winner_id: string | null;
scheduled_at: string | null;
completed_at: string | null;
games: SeriesGame[];
}
export interface SeriesIndex {
updated_at: string;
series: Series[];
}
// Season types
export interface SeasonMatch {
match_id: string;
week: number;
bot1_id: string;
bot2_id: string;
winner_id: string | null;
}
export interface SeasonSnapshot {
bot_id: string;
bot_name: string;
rating: number;
rank: number;
wins: number;
losses: number;
}
export interface Season {
id: string;
name: string;
theme: string;
rules_version: string;
status: 'upcoming' | 'active' | 'completed';
starts_at: string;
ends_at: string | null;
champion_id: string | null;
champion_name: string | null;
total_matches: number;
final_snapshot: SeasonSnapshot[] | null;
}
export interface SeasonIndex {
updated_at: string;
active_season: Season | null;
seasons: Season[];
}
// Prediction types
export interface Prediction {
id: string;
match_id: string;
predictor_id: string;
predicted_winner_slot: number;
actual_winner_slot: number | null;
correct: boolean | null;
created_at: string;
resolved_at: string | null;
}
export interface PredictorStats {
predictor_id: string;
predictor_name: string;
total_predictions: number;
correct_predictions: number;
accuracy: number;
streak: number;
best_streak: number;
}
export interface PredictionLeaderboard {
updated_at: string;
leaders: PredictorStats[];
}