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:
parent
f3e34c6736
commit
4ba39e3aa8
27 changed files with 1329790 additions and 267 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
677
cmd/acb-evolver/run.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ¬Found) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("head object %s: %w", key, err)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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++ {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
10771
web/public/data/demo-replay-v1.json
Normal file
10771
web/public/data/demo-replay-v1.json
Normal file
File diff suppressed because it is too large
Load diff
1208157
web/public/data/demo-replay-v2-6p.json
Normal file
1208157
web/public/data/demo-replay-v2-6p.json
Normal file
File diff suppressed because it is too large
Load diff
107106
web/public/data/demo-replay-v2.json
Normal file
107106
web/public/data/demo-replay-v2.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
205
web/src/components/event-timeline.ts
Normal file
205
web/src/components/event-timeline.ts
Normal 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;
|
||||
}
|
||||
`;
|
||||
|
|
@ -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
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
394
web/src/pages/predictions.ts
Normal file
394
web/src/pages/predictions.ts
Normal 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
442
web/src/pages/seasons.ts
Normal 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
440
web/src/pages/series.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
412
web/src/replay-commentary.ts
Normal file
412
web/src/replay-commentary.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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.',
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
117
web/src/types.ts
117
web/src/types.ts
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue