From f5d7553f9864aad328721aab44250a6afbf089f2 Mon Sep 17 00:00:00 2001 From: jedarden Date: Sun, 29 Mar 2026 01:13:23 -0400 Subject: [PATCH] Add Phase 7-9 features: evolution dashboard, WASM sandbox, enhanced replay Phase 7 Evolution: - Add live-export subcommand to acb-evolver for dashboard JSON generation - Export programs, stats, and generation log to live.json Phase 8 Enhanced Features: - Add WASM game engine build (cmd/acb-wasm/) with JS bindings - Add in-browser sandbox page with Monaco editor (web/src/pages/sandbox.ts) - Add win probability computation (web/src/win-probability.ts) - Add replay commentary generator (web/src/commentary.ts) - Add clip maker for GIF/MP4 export (web/src/pages/clip-maker.ts) - Add rivalry detection and pages (web/src/pages/rivalries.ts) - Add replay feedback system (web/src/pages/feedback.ts) - Add evolution dashboard page (web/src/pages/evolution.ts) Phase 9 Platform Depth: - Add predictions API (cmd/acb-api/predictions.go) - Add series management API (cmd/acb-api/series.go) - Add seasons API (cmd/acb-api/seasons.go) - Add narrative generator for rivalries (cmd/acb-indexer/src/narrative.ts) Engine Updates: - Add debug field to move response schema - Add match event timeline extraction - Add replay enrichment fields Web Updates: - Update app.html navigation for new pages - Add API client methods for predictions, series, seasons - Export engine types for browser use Co-Authored-By: Claude Opus 4.6 --- .gitignore | 7 + cmd/acb-api/predictions.go | 236 +++++++ cmd/acb-api/seasons.go | 248 +++++++ cmd/acb-api/series.go | 279 ++++++++ cmd/acb-evolver/internal/live/exporter.go | 267 ++++++++ cmd/acb-evolver/main.go | 30 +- cmd/acb-indexer/src/index.ts | 4 + cmd/acb-indexer/src/narrative.ts | 299 +++++++++ cmd/acb-indexer/src/types.ts | 73 ++ cmd/acb-indexer/src/writer.ts | 19 +- cmd/acb-wasm/botmain/gatherer/main.go | 47 ++ cmd/acb-wasm/botmain/guardian/main.go | 47 ++ cmd/acb-wasm/botmain/hunter/main.go | 47 ++ cmd/acb-wasm/botmain/random/main.go | 52 ++ cmd/acb-wasm/botmain/rusher/main.go | 47 ++ cmd/acb-wasm/botmain/swarm/main.go | 47 ++ cmd/acb-wasm/bots.go | 15 + cmd/acb-wasm/build.sh | 46 ++ cmd/acb-wasm/main.go | 210 ++++++ cmd/acb-wasm/strategies/strategies.go | 301 +++++++++ docs/plan/plan.md | 760 +++++++++++---------- engine/bot_http.go | 11 +- engine/match.go | 26 +- engine/replay.go | 43 +- web/app.html | 7 +- web/src/api-types.ts | 91 +++ web/src/app.ts | 10 + web/src/commentary.ts | 283 ++++++++ web/src/engine.ts | 687 +++++++++++++++++++ web/src/pages/clip-maker.ts | 780 ++++++++++++++++++++++ web/src/pages/evolution.ts | 592 ++++++++++++++++ web/src/pages/feedback.ts | 507 ++++++++++++++ web/src/pages/rivalries.ts | 341 ++++++++++ web/src/pages/sandbox.ts | 543 +++++++++++++++ web/src/types.ts | 1 + web/src/win-probability.ts | 282 ++++++++ 36 files changed, 6903 insertions(+), 382 deletions(-) create mode 100644 cmd/acb-api/predictions.go create mode 100644 cmd/acb-api/seasons.go create mode 100644 cmd/acb-api/series.go create mode 100644 cmd/acb-evolver/internal/live/exporter.go create mode 100644 cmd/acb-indexer/src/narrative.ts create mode 100644 cmd/acb-wasm/botmain/gatherer/main.go create mode 100644 cmd/acb-wasm/botmain/guardian/main.go create mode 100644 cmd/acb-wasm/botmain/hunter/main.go create mode 100644 cmd/acb-wasm/botmain/random/main.go create mode 100644 cmd/acb-wasm/botmain/rusher/main.go create mode 100644 cmd/acb-wasm/botmain/swarm/main.go create mode 100644 cmd/acb-wasm/bots.go create mode 100755 cmd/acb-wasm/build.sh create mode 100644 cmd/acb-wasm/main.go create mode 100644 cmd/acb-wasm/strategies/strategies.go create mode 100644 web/src/commentary.ts create mode 100644 web/src/engine.ts create mode 100644 web/src/pages/clip-maker.ts create mode 100644 web/src/pages/evolution.ts create mode 100644 web/src/pages/feedback.ts create mode 100644 web/src/pages/rivalries.ts create mode 100644 web/src/pages/sandbox.ts create mode 100644 web/src/win-probability.ts diff --git a/.gitignore b/.gitignore index db1c7ca..c976f4f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ /acb-local /acb-mapgen /acb-worker +/acb-api +/acb-matchmaker +/acb-evolver # Node modules node_modules/ @@ -30,3 +33,7 @@ replay.json # Marathon instructions (local only) .marathon/ + +# Development tools +.beads/ +.needle.yaml diff --git a/cmd/acb-api/predictions.go b/cmd/acb-api/predictions.go new file mode 100644 index 0000000..e589767 --- /dev/null +++ b/cmd/acb-api/predictions.go @@ -0,0 +1,236 @@ +package main + +import ( + "database/sql" + "encoding/json" + "errors" + "net/http" + "time" +) + +// handleSubmitPrediction handles POST /api/predictions +func (s *Server) handleSubmitPrediction(w http.ResponseWriter, r *http.Request) { + var req struct { + MatchID string `json:"match_id"` + PredictorID string `json:"predictor_id"` + PredictedBot string `json:"predicted_bot"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.MatchID == "" || req.PredictorID == "" || req.PredictedBot == "" { + writeError(w, http.StatusBadRequest, "match_id, predictor_id, and predicted_bot are required") + return + } + + ctx := r.Context() + + // Verify match exists and is pending/active + var matchStatus string + err := s.db.QueryRowContext(ctx, `SELECT status FROM matches WHERE match_id = $1`, req.MatchID).Scan(&matchStatus) + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "match not found") + return + } else if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + if matchStatus == "completed" { + writeError(w, http.StatusConflict, "match already completed; predictions closed") + return + } + + // Upsert prediction (one per predictor per match) + _, err = s.db.ExecContext(ctx, ` + INSERT INTO predictions (match_id, predictor_id, predicted_bot) + VALUES ($1, $2, $3) + ON CONFLICT (match_id, predictor_id) + DO UPDATE SET predicted_bot = EXCLUDED.predicted_bot + `, req.MatchID, req.PredictorID, req.PredictedBot) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to store prediction") + return + } + + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// handleResolvePredictions handles POST /api/predictions/{match_id}/resolve +// Called internally (worker or ticker) after a match completes. +func (s *Server) handleResolvePredictions(w http.ResponseWriter, r *http.Request) { + matchID := r.PathValue("match_id") + if matchID == "" { + writeError(w, http.StatusBadRequest, "missing match_id") + return + } + + ctx := r.Context() + + // Get match winner + var winnerID sql.NullString + err := s.db.QueryRowContext(ctx, ` + SELECT mp.bot_id FROM match_participants mp + JOIN matches m ON mp.match_id = m.match_id + WHERE m.match_id = $1 + AND mp.player_slot = m.winner + `, matchID).Scan(&winnerID) + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "match not found or has no winner") + return + } else if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + + winner := winnerID.String + + // Get all unresolved predictions for this match + rows, err := s.db.QueryContext(ctx, ` + SELECT id, predictor_id, predicted_bot + FROM predictions + WHERE match_id = $1 AND correct IS NULL + `, matchID) + if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + defer rows.Close() + + type predRow struct { + id int64 + predictorID string + predictedBot string + } + var preds []predRow + for rows.Next() { + var p predRow + if err := rows.Scan(&p.id, &p.predictorID, &p.predictedBot); err != nil { + continue + } + preds = append(preds, p) + } + + now := time.Now().UTC() + resolved := 0 + for _, p := range preds { + correct := p.predictedBot == winner + _, err := s.db.ExecContext(ctx, ` + UPDATE predictions SET correct = $1, resolved_at = $2 WHERE id = $3 + `, correct, now, p.id) + if err != nil { + continue + } + + // Update predictor stats + if correct { + _, _ = s.db.ExecContext(ctx, ` + INSERT INTO predictor_stats (predictor_id, correct, streak, best_streak) + VALUES ($1, 1, 1, 1) + ON CONFLICT (predictor_id) DO UPDATE SET + correct = predictor_stats.correct + 1, + streak = predictor_stats.streak + 1, + best_streak = GREATEST(predictor_stats.best_streak, predictor_stats.streak + 1), + updated_at = NOW() + `, p.predictorID) + } else { + _, _ = s.db.ExecContext(ctx, ` + INSERT INTO predictor_stats (predictor_id, incorrect, streak) + VALUES ($1, 1, 0) + ON CONFLICT (predictor_id) DO UPDATE SET + incorrect = predictor_stats.incorrect + 1, + streak = 0, + updated_at = NOW() + `, p.predictorID) + } + resolved++ + } + + writeJSON(w, http.StatusOK, map[string]int{"resolved": resolved}) +} + +// handlePredictionLeaderboard handles GET /api/predictions/leaderboard +func (s *Server) handlePredictionLeaderboard(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + rows, err := s.db.QueryContext(ctx, ` + SELECT predictor_id, correct, incorrect, + CASE WHEN (correct + incorrect) > 0 + THEN ROUND(100.0 * correct / (correct + incorrect), 1) + ELSE 0 END AS accuracy, + streak, best_streak + FROM predictor_stats + WHERE (correct + incorrect) >= 5 + ORDER BY accuracy DESC, correct DESC + LIMIT 100 + `) + if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + defer rows.Close() + + type entry struct { + PredictorID string `json:"predictor_id"` + Correct int `json:"correct"` + Incorrect int `json:"incorrect"` + Accuracy float64 `json:"accuracy"` + Streak int `json:"streak"` + BestStreak int `json:"best_streak"` + } + entries := make([]entry, 0) + for rows.Next() { + var e entry + if err := rows.Scan(&e.PredictorID, &e.Correct, &e.Incorrect, &e.Accuracy, &e.Streak, &e.BestStreak); err != nil { + continue + } + entries = append(entries, e) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "leaderboard": entries, + "updated_at": time.Now().UTC(), + }) +} + +// handleGetPredictions handles GET /api/predictions/{match_id} +func (s *Server) handleGetPredictions(w http.ResponseWriter, r *http.Request) { + matchID := r.PathValue("match_id") + ctx := r.Context() + + rows, err := s.db.QueryContext(ctx, ` + SELECT predictor_id, predicted_bot, correct + FROM predictions + WHERE match_id = $1 + ORDER BY created_at DESC + `, matchID) + if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + defer rows.Close() + + type pred struct { + PredictorID string `json:"predictor_id"` + PredictedBot string `json:"predicted_bot"` + Correct *bool `json:"correct"` + } + preds := make([]pred, 0) + for rows.Next() { + var p pred + var correct sql.NullBool + if err := rows.Scan(&p.PredictorID, &p.PredictedBot, &correct); err != nil { + continue + } + if correct.Valid { + b := correct.Bool + p.Correct = &b + } + preds = append(preds, p) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "match_id": matchID, + "predictions": preds, + }) +} diff --git a/cmd/acb-api/seasons.go b/cmd/acb-api/seasons.go new file mode 100644 index 0000000..d02b53d --- /dev/null +++ b/cmd/acb-api/seasons.go @@ -0,0 +1,248 @@ +package main + +import ( + "database/sql" + "encoding/json" + "errors" + "net/http" + "time" +) + +// handleListSeasons handles GET /api/seasons +func (s *Server) handleListSeasons(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + rows, err := s.db.QueryContext(ctx, ` + SELECT id, name, theme, rules_version, status, champion_id, starts_at, ends_at, created_at + FROM seasons + ORDER BY created_at DESC + LIMIT 20 + `) + if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + defer rows.Close() + + type seasonEntry struct { + ID int64 `json:"id"` + Name string `json:"name"` + Theme *string `json:"theme"` + RulesVersion string `json:"rules_version"` + Status string `json:"status"` + ChampionID *string `json:"champion_id"` + StartsAt time.Time `json:"starts_at"` + EndsAt *time.Time `json:"ends_at"` + CreatedAt time.Time `json:"created_at"` + } + seasons := make([]seasonEntry, 0) + for rows.Next() { + var se seasonEntry + var theme, championID sql.NullString + var endsAt sql.NullTime + if err := rows.Scan(&se.ID, &se.Name, &theme, &se.RulesVersion, &se.Status, + &championID, &se.StartsAt, &endsAt, &se.CreatedAt); err != nil { + continue + } + if theme.Valid { + se.Theme = &theme.String + } + if championID.Valid { + se.ChampionID = &championID.String + } + if endsAt.Valid { + se.EndsAt = &endsAt.Time + } + seasons = append(seasons, se) + } + + writeJSON(w, http.StatusOK, map[string]any{"seasons": seasons}) +} + +// handleCreateSeason handles POST /api/seasons +func (s *Server) handleCreateSeason(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Theme string `json:"theme"` + RulesVersion string `json:"rules_version"` + EndsAt string `json:"ends_at"` // RFC3339 + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + if req.RulesVersion == "" { + req.RulesVersion = "1.0" + } + + ctx := r.Context() + + var endsAt sql.NullTime + if req.EndsAt != "" { + t, err := time.Parse(time.RFC3339, req.EndsAt) + if err == nil { + endsAt = sql.NullTime{Time: t, Valid: true} + } + } + + var id int64 + err := s.db.QueryRowContext(ctx, ` + INSERT INTO seasons (name, theme, rules_version, ends_at) + VALUES ($1, $2, $3, $4) + RETURNING id + `, req.Name, req.Theme, req.RulesVersion, endsAt).Scan(&id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create season") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"season_id": id, "ok": true}) +} + +// handleGetSeason handles GET /api/seasons/{id} +func (s *Server) handleGetSeason(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + ctx := r.Context() + + var se struct { + ID int64 `json:"id"` + Name string `json:"name"` + Theme *string `json:"theme"` + RulesVersion string `json:"rules_version"` + Status string `json:"status"` + ChampionID *string `json:"champion_id"` + StartsAt time.Time `json:"starts_at"` + EndsAt *time.Time `json:"ends_at"` + } + var theme, championID sql.NullString + var endsAt sql.NullTime + err := s.db.QueryRowContext(ctx, ` + SELECT id, name, theme, rules_version, status, champion_id, starts_at, ends_at + FROM seasons WHERE id = $1 + `, id).Scan(&se.ID, &se.Name, &theme, &se.RulesVersion, &se.Status, + &championID, &se.StartsAt, &endsAt) + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "season not found") + return + } else if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + if theme.Valid { + se.Theme = &theme.String + } + if championID.Valid { + se.ChampionID = &championID.String + } + if endsAt.Valid { + se.EndsAt = &endsAt.Time + } + + // Get leaderboard snapshot for this season + rows, err := s.db.QueryContext(ctx, ` + SELECT ss.bot_id, b.name, ss.rank, ss.rating, ss.wins, ss.losses, ss.recorded_at + FROM season_snapshots ss + JOIN bots b ON ss.bot_id = b.bot_id + WHERE ss.season_id = $1 + ORDER BY ss.rank + LIMIT 50 + `, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + defer rows.Close() + + type snap struct { + BotID string `json:"bot_id"` + BotName string `json:"bot_name"` + Rank int `json:"rank"` + Rating float64 `json:"rating"` + Wins int `json:"wins"` + Losses int `json:"losses"` + RecordedAt time.Time `json:"recorded_at"` + } + snapshots := make([]snap, 0) + for rows.Next() { + var sn snap + if err := rows.Scan(&sn.BotID, &sn.BotName, &sn.Rank, &sn.Rating, &sn.Wins, &sn.Losses, &sn.RecordedAt); err != nil { + continue + } + snapshots = append(snapshots, sn) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "season": se, + "standings": snapshots, + }) +} + +// handleSnapshotSeason handles POST /api/seasons/{id}/snapshot +// Takes a snapshot of the current leaderboard for the season archive. +func (s *Server) handleSnapshotSeason(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + ctx := r.Context() + + // Check season exists + var seasonName string + err := s.db.QueryRowContext(ctx, `SELECT name FROM seasons WHERE id = $1`, id).Scan(&seasonName) + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "season not found") + return + } + + // Take snapshot of current leaderboard + _, err = s.db.ExecContext(ctx, ` + INSERT INTO season_snapshots (season_id, bot_id, rank, rating, wins, losses) + SELECT $1, bot_id, + ROW_NUMBER() OVER (ORDER BY rating_mu DESC), + rating_mu, + (SELECT COUNT(*) FROM match_participants mp2 + JOIN matches m2 ON mp2.match_id = m2.match_id + WHERE mp2.bot_id = b.bot_id AND m2.status = 'completed' + AND m2.winner = mp2.player_slot), + (SELECT COUNT(*) FROM match_participants mp3 + JOIN matches m3 ON mp3.match_id = m3.match_id + WHERE mp3.bot_id = b.bot_id AND m3.status = 'completed' + AND m3.winner != mp3.player_slot AND m3.winner >= 0) + FROM bots b + WHERE status = 'active' + ORDER BY rating_mu DESC + LIMIT 100 + `, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to snapshot season") + return + } + + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// handleCloseSeason handles POST /api/seasons/{id}/close +func (s *Server) handleCloseSeason(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + ctx := r.Context() + + // Find current leader + var championID sql.NullString + _ = s.db.QueryRowContext(ctx, ` + SELECT bot_id FROM season_snapshots + WHERE season_id = $1 + ORDER BY rank ASC LIMIT 1 + `, id).Scan(&championID) + + _, err := s.db.ExecContext(ctx, ` + UPDATE seasons SET status = 'archived', champion_id = $1, ends_at = NOW() + WHERE id = $2 + `, championID, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to close season") + return + } + + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} diff --git a/cmd/acb-api/series.go b/cmd/acb-api/series.go new file mode 100644 index 0000000..fc9d4ff --- /dev/null +++ b/cmd/acb-api/series.go @@ -0,0 +1,279 @@ +package main + +import ( + "database/sql" + "encoding/json" + "errors" + "net/http" + "time" +) + +// handleCreateSeries handles POST /api/series +func (s *Server) handleCreateSeries(w http.ResponseWriter, r *http.Request) { + var req struct { + BotAID string `json:"bot_a_id"` + BotBID string `json:"bot_b_id"` + Format int `json:"format"` // best of N + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.BotAID == "" || req.BotBID == "" { + writeError(w, http.StatusBadRequest, "bot_a_id and bot_b_id are required") + return + } + if req.Format < 1 { + req.Format = 5 + } + + ctx := r.Context() + + var id int64 + err := s.db.QueryRowContext(ctx, ` + INSERT INTO series (bot_a_id, bot_b_id, format) + VALUES ($1, $2, $3) + RETURNING id + `, req.BotAID, req.BotBID, req.Format).Scan(&id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create series") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"series_id": id, "ok": true}) +} + +// handleListSeries handles GET /api/series +func (s *Server) handleListSeries(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + rows, err := s.db.QueryContext(ctx, ` + SELECT s.id, s.bot_a_id, ba.name, s.bot_b_id, bb.name, + s.format, s.a_wins, s.b_wins, s.status, s.winner_id, + s.created_at, s.updated_at + FROM series s + JOIN bots ba ON s.bot_a_id = ba.bot_id + JOIN bots bb ON s.bot_b_id = bb.bot_id + ORDER BY s.updated_at DESC + LIMIT 50 + `) + if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + defer rows.Close() + + type seriesEntry struct { + ID int64 `json:"id"` + BotAID string `json:"bot_a_id"` + BotAName string `json:"bot_a_name"` + BotBID string `json:"bot_b_id"` + BotBName string `json:"bot_b_name"` + Format int `json:"format"` + AWins int `json:"a_wins"` + BWins int `json:"b_wins"` + Status string `json:"status"` + WinnerID *string `json:"winner_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + } + entries := make([]seriesEntry, 0) + for rows.Next() { + var e seriesEntry + var winnerID sql.NullString + if err := rows.Scan(&e.ID, &e.BotAID, &e.BotAName, &e.BotBID, &e.BotBName, + &e.Format, &e.AWins, &e.BWins, &e.Status, &winnerID, + &e.CreatedAt, &e.UpdatedAt); err != nil { + continue + } + if winnerID.Valid { + e.WinnerID = &winnerID.String + } + entries = append(entries, e) + } + + writeJSON(w, http.StatusOK, map[string]any{"series": entries}) +} + +// handleGetSeries handles GET /api/series/{id} +func (s *Server) handleGetSeries(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + ctx := r.Context() + + type game struct { + MatchID string `json:"match_id"` + GameNum int `json:"game_num"` + WinnerID *string `json:"winner_id"` + CreatedAt time.Time `json:"created_at"` + } + + rows, err := s.db.QueryContext(ctx, ` + SELECT match_id, game_num, winner_id, created_at + FROM series_games + WHERE series_id = $1 + ORDER BY game_num + `, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + defer rows.Close() + + games := make([]game, 0) + for rows.Next() { + var g game + var winnerID sql.NullString + if err := rows.Scan(&g.MatchID, &g.GameNum, &winnerID, &g.CreatedAt); err != nil { + continue + } + if winnerID.Valid { + g.WinnerID = &winnerID.String + } + games = append(games, g) + } + + // Get series header + var se struct { + ID int64 `json:"id"` + BotAID string `json:"bot_a_id"` + BotAName string `json:"bot_a_name"` + BotBID string `json:"bot_b_id"` + BotBName string `json:"bot_b_name"` + Format int `json:"format"` + AWins int `json:"a_wins"` + BWins int `json:"b_wins"` + Status string `json:"status"` + WinnerID *string `json:"winner_id"` + CreatedAt time.Time `json:"created_at"` + } + var winnerID sql.NullString + err = s.db.QueryRowContext(ctx, ` + SELECT s.id, s.bot_a_id, ba.name, s.bot_b_id, bb.name, + s.format, s.a_wins, s.b_wins, s.status, s.winner_id, s.created_at + FROM series s + JOIN bots ba ON s.bot_a_id = ba.bot_id + JOIN bots bb ON s.bot_b_id = bb.bot_id + WHERE s.id = $1 + `, id).Scan(&se.ID, &se.BotAID, &se.BotAName, &se.BotBID, &se.BotBName, + &se.Format, &se.AWins, &se.BWins, &se.Status, &winnerID, &se.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "series not found") + return + } else if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + if winnerID.Valid { + se.WinnerID = &winnerID.String + } + + writeJSON(w, http.StatusOK, map[string]any{ + "series": se, + "games": games, + }) +} + +// handleAddSeriesGame handles POST /api/series/{id}/games +func (s *Server) handleAddSeriesGame(w http.ResponseWriter, r *http.Request) { + seriesID := r.PathValue("id") + var req struct { + MatchID string `json:"match_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + ctx := r.Context() + + // Get series info + var botAID, botBID string + var format, aWins, bWins int + var status string + err := s.db.QueryRowContext(ctx, ` + SELECT bot_a_id, bot_b_id, format, a_wins, b_wins, status + FROM series WHERE id = $1 + `, seriesID).Scan(&botAID, &botBID, &format, &aWins, &bWins, &status) + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "series not found") + return + } else if err != nil { + writeError(w, http.StatusInternalServerError, "database error") + return + } + if status != "active" { + writeError(w, http.StatusConflict, "series is not active") + return + } + + // Get match winner + var matchWinnerSlot sql.NullInt64 + err = s.db.QueryRowContext(ctx, `SELECT winner FROM matches WHERE match_id = $1`, req.MatchID).Scan(&matchWinnerSlot) + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "match not found") + return + } + + // Determine which bot won + var winnerBotID sql.NullString + if matchWinnerSlot.Valid { + slot := int(matchWinnerSlot.Int64) + if slot == 0 { + winnerBotID.String = botAID + winnerBotID.Valid = true + } else if slot == 1 { + winnerBotID.String = botBID + winnerBotID.Valid = true + } + } + + // Get next game number + var gameNum int + _ = s.db.QueryRowContext(ctx, ` + SELECT COALESCE(MAX(game_num), 0) + 1 FROM series_games WHERE series_id = $1 + `, seriesID).Scan(&gameNum) + + // Insert game + _, err = s.db.ExecContext(ctx, ` + INSERT INTO series_games (series_id, match_id, game_num, winner_id) + VALUES ($1, $2, $3, $4) + `, seriesID, req.MatchID, gameNum, winnerBotID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to add game") + return + } + + // Update win counts and check if series is decided + if winnerBotID.Valid { + if winnerBotID.String == botAID { + aWins++ + } else { + bWins++ + } + } + + toWin := (format / 2) + 1 + newStatus := "active" + var seriesWinner sql.NullString + if aWins >= toWin { + newStatus = "completed" + seriesWinner.String = botAID + seriesWinner.Valid = true + } else if bWins >= toWin { + newStatus = "completed" + seriesWinner.String = botBID + seriesWinner.Valid = true + } + + _, _ = s.db.ExecContext(ctx, ` + UPDATE series SET a_wins=$1, b_wins=$2, status=$3, winner_id=$4, updated_at=NOW() + WHERE id = $5 + `, aWins, bWins, newStatus, seriesWinner, seriesID) + + writeJSON(w, http.StatusOK, map[string]any{ + "game_num": gameNum, + "a_wins": aWins, + "b_wins": bWins, + "status": newStatus, + }) +} diff --git a/cmd/acb-evolver/internal/live/exporter.go b/cmd/acb-evolver/internal/live/exporter.go new file mode 100644 index 0000000..afcfa53 --- /dev/null +++ b/cmd/acb-evolver/internal/live/exporter.go @@ -0,0 +1,267 @@ +// Package live generates the evolution dashboard live.json snapshot. +package live + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "math" + "os" + "sort" + "time" +) + +// IslandStat holds per-island population statistics. +type IslandStat struct { + Count int `json:"count"` + BestFitness float64 `json:"best_fitness"` + AvgFitness float64 `json:"avg_fitness"` + Diversity float64 `json:"diversity"` // language diversity [0,1] + PromotedCount int `json:"promoted_count"` +} + +// GenerationEntry is one row in the generation log (island × generation bucket). +type GenerationEntry struct { + Generation int `json:"generation"` + Island string `json:"island"` + EvaluatedAt string `json:"evaluated_at"` + Count int `json:"count"` + Promoted int `json:"promoted"` + BestFitness float64 `json:"best_fitness"` + AvgFitness float64 `json:"avg_fitness"` +} + +// LineageNode is a single program in the lineage tree. +type LineageNode struct { + ID int64 `json:"id"` + ParentIDs []int64 `json:"parent_ids"` + Generation int `json:"generation"` + Island string `json:"island"` + Fitness float64 `json:"fitness"` + Promoted bool `json:"promoted"` + Language string `json:"language"` + CreatedAt string `json:"created_at"` +} + +// MetaSnapshot is the island population state at a single generation. +type MetaSnapshot struct { + Generation int `json:"generation"` + IslandCounts map[string]int `json:"island_counts"` + IslandBestFitness map[string]float64 `json:"island_best_fitness"` +} + +// LiveData is the full evolution dashboard payload written to live.json. +type LiveData struct { + UpdatedAt string `json:"updated_at"` + TotalPrograms int `json:"total_programs"` + PromotedCount int `json:"promoted_count"` + Islands map[string]IslandStat `json:"islands"` + GenerationLog []GenerationEntry `json:"generation_log"` + Lineage []LineageNode `json:"lineage"` + MetaSnapshots []MetaSnapshot `json:"meta_snapshots"` +} + +// Export queries the programs database and builds the current evolution state. +func Export(ctx context.Context, db *sql.DB) (*LiveData, error) { + data := &LiveData{ + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + Islands: make(map[string]IslandStat), + } + + if err := fillIslandStats(ctx, db, data); err != nil { + return nil, err + } + if err := fillGenerationLog(ctx, db, data); err != nil { + return nil, err + } + if err := fillLineage(ctx, db, data); err != nil { + return nil, err + } + if err := fillMetaSnapshots(ctx, db, data); err != nil { + return nil, err + } + + return data, nil +} + +func fillIslandStats(ctx context.Context, db *sql.DB, data *LiveData) error { + rows, err := db.QueryContext(ctx, ` + SELECT island, + COUNT(*) AS cnt, + COALESCE(AVG(fitness), 0) AS avg_fit, + COALESCE(MAX(fitness), 0) AS max_fit, + COUNT(DISTINCT language) AS lang_diversity, + SUM(CASE WHEN promoted AND bot_id IS NOT NULL THEN 1 ELSE 0 END) AS promoted_cnt + FROM programs + GROUP BY island`) + if err != nil { + return fmt.Errorf("island stats: %w", err) + } + defer rows.Close() + + total := 0 + promoted := 0 + for rows.Next() { + var island string + var cnt, langDiv, promotedCnt int + var avgFit, maxFit float64 + if err := rows.Scan(&island, &cnt, &avgFit, &maxFit, &langDiv, &promotedCnt); err != nil { + return fmt.Errorf("scan island stats: %w", err) + } + // Diversity: language diversity normalized to [0,1], up to 6 languages + const maxLangs = 6.0 + diversity := float64(langDiv) / maxLangs + if diversity > 1.0 { + diversity = 1.0 + } + data.Islands[island] = IslandStat{ + Count: cnt, + BestFitness: round3(maxFit), + AvgFitness: round3(avgFit), + Diversity: round3(diversity), + PromotedCount: promotedCnt, + } + total += cnt + promoted += promotedCnt + } + if err := rows.Err(); err != nil { + return err + } + data.TotalPrograms = total + data.PromotedCount = promoted + return nil +} + +func fillGenerationLog(ctx context.Context, db *sql.DB, data *LiveData) error { + rows, err := db.QueryContext(ctx, ` + SELECT generation, island, + MAX(created_at) AS latest, + COUNT(*) AS cnt, + SUM(CASE WHEN promoted AND bot_id IS NOT NULL THEN 1 ELSE 0 END) AS promoted_cnt, + COALESCE(MAX(fitness), 0) AS max_fit, + COALESCE(AVG(fitness), 0) AS avg_fit + FROM programs + GROUP BY generation, island + ORDER BY generation DESC, island + LIMIT 100`) + if err != nil { + return fmt.Errorf("generation log: %w", err) + } + defer rows.Close() + + for rows.Next() { + var e GenerationEntry + var latest time.Time + if err := rows.Scan(&e.Generation, &e.Island, &latest, &e.Count, &e.Promoted, &e.BestFitness, &e.AvgFitness); err != nil { + return fmt.Errorf("scan gen log: %w", err) + } + e.EvaluatedAt = latest.UTC().Format(time.RFC3339) + e.BestFitness = round3(e.BestFitness) + e.AvgFitness = round3(e.AvgFitness) + data.GenerationLog = append(data.GenerationLog, e) + } + return rows.Err() +} + +func fillLineage(ctx context.Context, db *sql.DB, data *LiveData) error { + rows, err := db.QueryContext(ctx, ` + SELECT id, parent_ids, generation, island, fitness, promoted, language, created_at + FROM programs + ORDER BY created_at DESC + LIMIT 200`) + if err != nil { + return fmt.Errorf("lineage: %w", err) + } + defer rows.Close() + + for rows.Next() { + var node LineageNode + var parentJSON string + var createdAt time.Time + if err := rows.Scan(&node.ID, &parentJSON, &node.Generation, &node.Island, + &node.Fitness, &node.Promoted, &node.Language, &createdAt); err != nil { + return fmt.Errorf("scan lineage: %w", err) + } + if err := json.Unmarshal([]byte(parentJSON), &node.ParentIDs); err != nil { + node.ParentIDs = []int64{} + } + node.Fitness = round3(node.Fitness) + node.CreatedAt = createdAt.UTC().Format(time.RFC3339) + data.Lineage = append(data.Lineage, node) + } + return rows.Err() +} + +func fillMetaSnapshots(ctx context.Context, db *sql.DB, data *LiveData) error { + rows, err := db.QueryContext(ctx, ` + SELECT generation, island, COUNT(*), COALESCE(MAX(fitness), 0) + FROM programs + GROUP BY generation, island + ORDER BY generation ASC`) + if err != nil { + return fmt.Errorf("meta snapshots: %w", err) + } + defer rows.Close() + + snapMap := make(map[int]*MetaSnapshot) + for rows.Next() { + var gen, cnt int + var island string + var maxFit float64 + if err := rows.Scan(&gen, &island, &cnt, &maxFit); err != nil { + return fmt.Errorf("scan meta snapshots: %w", err) + } + if snapMap[gen] == nil { + snapMap[gen] = &MetaSnapshot{ + Generation: gen, + IslandCounts: make(map[string]int), + IslandBestFitness: make(map[string]float64), + } + } + snapMap[gen].IslandCounts[island] = cnt + snapMap[gen].IslandBestFitness[island] = round3(maxFit) + } + if err := rows.Err(); err != nil { + return err + } + + gens := make([]int, 0, len(snapMap)) + for gen := range snapMap { + gens = append(gens, gen) + } + sort.Ints(gens) + for _, gen := range gens { + data.MetaSnapshots = append(data.MetaSnapshots, *snapMap[gen]) + } + return nil +} + +// WriteFile marshals the live data to JSON and writes it to path, creating +// parent directories if needed. +func WriteFile(d *LiveData, path string) error { + b, err := json.MarshalIndent(d, "", " ") + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + if err := os.MkdirAll(dirOf(path), 0755); err != nil { + return fmt.Errorf("mkdir: %w", err) + } + if err := os.WriteFile(path, b, 0644); err != nil { + return fmt.Errorf("write: %w", err) + } + return nil +} + +func dirOf(p string) string { + for i := len(p) - 1; i >= 0; i-- { + if p[i] == '/' || p[i] == '\\' { + return p[:i] + } + } + return "." +} + +func round3(v float64) float64 { + return math.Round(v*1000) / 1000 +} diff --git a/cmd/acb-evolver/main.go b/cmd/acb-evolver/main.go index a93dd50..b131f3c 100644 --- a/cmd/acb-evolver/main.go +++ b/cmd/acb-evolver/main.go @@ -9,6 +9,7 @@ // validation-stats Show per-island validation pass-rate metrics // evaluate Run the 10-match arena tournament and apply the promotion gate // retire Enforce retirement policy (rating threshold + population cap) +// live-export Export evolution state to live.json for the dashboard package main import ( @@ -24,6 +25,7 @@ import ( 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/mapelites" "github.com/aicodebattle/acb/cmd/acb-evolver/internal/promoter" "github.com/aicodebattle/acb/cmd/acb-evolver/internal/validator" @@ -43,6 +45,11 @@ func main() { ctx := context.Background() switch os.Args[1] { + case "live-export": + db := mustOpenDB(dbURL) + defer db.Close() + runLiveExport(ctx, db, os.Args[2:]) + case "evaluate": db := mustOpenDB(dbURL) defer db.Close() @@ -105,7 +112,7 @@ func main() { default: fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", os.Args[1]) - fmt.Fprintln(os.Stderr, "usage: acb-evolver ") + fmt.Fprintln(os.Stderr, "usage: acb-evolver ") os.Exit(1) } } @@ -475,6 +482,27 @@ func runValidationStats(ctx context.Context, store *evolverdb.Store) { } } +// runLiveExport exports the current evolution state to live.json. +// +// live-export [-out evolution/live.json] +func runLiveExport(ctx context.Context, db *sql.DB, args []string) { + fs := flag.NewFlagSet("live-export", flag.ExitOnError) + out := fs.String("out", envOrDefault("ACB_EVOLUTION_OUT", "evolution/live.json"), "output file path") + if err := fs.Parse(args); err != nil { + os.Exit(1) + } + + data, err := live.Export(ctx, db) + if err != nil { + log.Fatalf("live-export: %v", err) + } + if err := live.WriteFile(data, *out); err != nil { + log.Fatalf("live-export write: %v", err) + } + log.Printf("live-export: wrote %d programs (%d promoted) to %s", + data.TotalPrograms, data.PromotedCount, *out) +} + func mustOpenDB(url string) *sql.DB { db, err := sql.Open("postgres", url) if err != nil { diff --git a/cmd/acb-indexer/src/index.ts b/cmd/acb-indexer/src/index.ts index a6526a0..58f15f3 100644 --- a/cmd/acb-indexer/src/index.ts +++ b/cmd/acb-indexer/src/index.ts @@ -11,6 +11,7 @@ import 'dotenv/config'; import { ApiClient } from './api.js'; import { IndexGenerator } from './generator.js'; import { FileWriter } from './writer.js'; +import type { EvolutionLiveData } from './types.js'; const execAsync = promisify(exec); @@ -19,6 +20,7 @@ interface Config { apiKey: string; outputDir: string; deployCommand?: string; + evolutionDataPath?: string; } function getConfig(): Config { @@ -26,6 +28,7 @@ function getConfig(): Config { const apiKey = process.env.API_KEY; const outputDir = process.env.OUTPUT_DIR || './data'; const deployCommand = process.env.DEPLOY_COMMAND; + const evolutionDataPath = process.env.EVOLUTION_DATA_PATH; if (!apiUrl) { console.error('ERROR: API_URL environment variable is required'); @@ -42,6 +45,7 @@ function getConfig(): Config { apiKey, outputDir, deployCommand, + evolutionDataPath, }; } diff --git a/cmd/acb-indexer/src/narrative.ts b/cmd/acb-indexer/src/narrative.ts new file mode 100644 index 0000000..f435d59 --- /dev/null +++ b/cmd/acb-indexer/src/narrative.ts @@ -0,0 +1,299 @@ +// Narrative Engine - generates weekly meta report blog posts from match data. +// Optionally enhances prose via the Anthropic API when ANTHROPIC_API_KEY is set. + +import type { + ExportData, + ExportMatch, + ExportBot, + BlogPost, + BlogWeekStats, + BlogIndex, + EvolutionLiveData, +} from './types.js'; + +// --------------------------------------------------------------------------- +// Week helpers +// --------------------------------------------------------------------------- + +function startOfWeek(d: Date): Date { + const day = d.getUTCDay(); // 0=Sun + const diff = (day === 0 ? -6 : 1 - day); // Monday + const out = new Date(d); + out.setUTCDate(d.getUTCDate() + diff); + out.setUTCHours(0, 0, 0, 0); + return out; +} + +function isoDate(d: Date): string { + return d.toISOString().slice(0, 10); +} + +function weekSlug(weekStart: Date): string { + return `week-${isoDate(weekStart)}`; +} + +// --------------------------------------------------------------------------- +// Stats extraction +// --------------------------------------------------------------------------- + +function matchesInWeek(matches: ExportMatch[], weekStart: Date): ExportMatch[] { + const start = weekStart.getTime(); + const end = start + 7 * 24 * 60 * 60 * 1000; + return matches.filter(m => { + if (!m.completed_at) return false; + const t = new Date(m.completed_at).getTime(); + return t >= start && t < end; + }); +} + +function computeWeekStats( + weekMatches: ExportMatch[], + bots: ExportBot[], + evo: EvolutionLiveData | null, +): BlogWeekStats { + const botMap = new Map(bots.map(b => [b.id, b])); + + // Top bot by rating + const sorted = [...bots].sort((a, b) => b.rating - a.rating); + const topBot = sorted[0]; + + // Match activity per bot + const activityCount = new Map(); + for (const m of weekMatches) { + for (const p of m.participants) { + activityCount.set(p.bot_id, (activityCount.get(p.bot_id) ?? 0) + 1); + } + } + let mostActiveBot = topBot?.name ?? 'N/A'; + let mostActiveBotMatches = 0; + for (const [id, count] of activityCount) { + if (count > mostActiveBotMatches) { + mostActiveBotMatches = count; + mostActiveBot = botMap.get(id)?.name ?? id; + } + } + + // Biggest upset: lower-rated bot beats higher-rated by the largest margin + let biggestUpset: string | null = null; + let maxUpsetMargin = 0; + for (const m of weekMatches) { + if (!m.winner_id || m.participants.length < 2) continue; + const winner = m.participants.find(p => p.bot_id === m.winner_id); + if (!winner) continue; + const loser = m.participants.find(p => p.bot_id !== m.winner_id); + if (!loser) continue; + const winnerBot = botMap.get(winner.bot_id); + const loserBot = botMap.get(loser.bot_id); + if (!winnerBot || !loserBot) continue; + const margin = loserBot.rating - winnerBot.rating; + if (margin > maxUpsetMargin) { + maxUpsetMargin = margin; + biggestUpset = `${winnerBot.name} defeated ${loserBot.name} (+${Math.round(margin)} rating gap)`; + } + } + + // Island leader from evolution data + let islandLeader: string | null = null; + if (evo) { + let bestFitness = -Infinity; + for (const [island, stat] of Object.entries(evo.islands)) { + if (stat.best_fitness > bestFitness) { + bestFitness = stat.best_fitness; + islandLeader = island; + } + } + } + + return { + matches_played: weekMatches.length, + top_bot: topBot?.name ?? 'N/A', + top_bot_rating: Math.round(topBot?.rating ?? 0), + biggest_upset: biggestUpset, + most_active_bot: mostActiveBot, + most_active_bot_matches: mostActiveBotMatches, + island_leader: islandLeader, + }; +} + +// --------------------------------------------------------------------------- +// Template-based narrative (used when no LLM key is available) +// --------------------------------------------------------------------------- + +function templateNarrative(weekStart: Date, stats: BlogWeekStats): { title: string; summary: string; body_html: string } { + const weekLabel = isoDate(weekStart); + const title = `Meta Report: Week of ${weekLabel}`; + + const summary = + `This week ${stats.matches_played} matches were played. ` + + `${stats.top_bot} leads the leaderboard at ${stats.top_bot_rating} rating. ` + + (stats.biggest_upset + ? `The biggest upset saw ${stats.biggest_upset}. ` + : '') + + `${stats.most_active_bot} was the most active with ${stats.most_active_bot_matches} matches.`; + + const upsetSection = stats.biggest_upset + ? `

Biggest Upset

+

${stats.biggest_upset}.

` + : ''; + + const evoSection = stats.island_leader + ? `

Evolution Observatory

+

Island ${stats.island_leader} leads the evolution pipeline this week.

` + : ''; + + const body_html = ` +

Overview

+

+ The week of ${weekLabel} produced ${stats.matches_played} completed matches + on the AI Code Battle platform. +

+ +

Leaderboard Snapshot

+

+ ${stats.top_bot} holds the top position with a rating of + ${stats.top_bot_rating}. The competition remains fierce as bots jockey + for position in the weekly rankings. +

+ +

Most Active Competitor

+

+ ${stats.most_active_bot} played the most matches this week + (${stats.most_active_bot_matches} games), demonstrating consistent + availability and aggressive scheduling. +

+ +${upsetSection} + +${evoSection} + +

What to Watch

+

+ With the meta always shifting, next week promises fresh rivalries and strategy evolution. + Keep an eye on the Evolution Dashboard for emerging program + lineages and the Rivalries page for head-to-head trends. +

+`.trim(); + + return { title, summary, body_html }; +} + +// --------------------------------------------------------------------------- +// LLM-enhanced narrative (Anthropic API) +// --------------------------------------------------------------------------- + +async function llmNarrative( + weekStart: Date, + stats: BlogWeekStats, + templateResult: { title: string; summary: string; body_html: string }, +): Promise<{ title: string; summary: string; body_html: string }> { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) return templateResult; + + const prompt = `You are a sports journalist covering an AI bot programming competition. +Write a short, engaging weekly meta report for the week of ${isoDate(weekStart)}. + +Statistics: +- Matches played: ${stats.matches_played} +- Top bot: ${stats.top_bot} (rating: ${stats.top_bot_rating}) +- Most active bot: ${stats.most_active_bot} (${stats.most_active_bot_matches} matches) +- Biggest upset: ${stats.biggest_upset ?? 'none this week'} +- Evolution island leader: ${stats.island_leader ?? 'data not available'} + +Write: +1. A catchy title (one line, no markdown) +2. A one-paragraph summary (plain text, 2-3 sentences) +3. Full HTML body content (use

,

,

tags; no //) + +Format your response as JSON with keys: title, summary, body_html`; + + try { + const res = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 1024, + messages: [{ role: 'user', content: prompt }], + }), + }); + + if (!res.ok) { + console.warn(`LLM API returned ${res.status}, falling back to template narrative`); + return templateResult; + } + + const json = await res.json() as { content: Array<{ text: string }> }; + const text = json.content[0]?.text ?? ''; + + // Extract JSON from response (may be wrapped in markdown code fences) + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + console.warn('LLM response did not contain JSON, using template'); + return templateResult; + } + + const parsed = JSON.parse(jsonMatch[0]) as { title?: string; summary?: string; body_html?: string }; + return { + title: parsed.title ?? templateResult.title, + summary: parsed.summary ?? templateResult.summary, + body_html: parsed.body_html ?? templateResult.body_html, + }; + } catch (err) { + console.warn('LLM narrative failed, using template:', err); + return templateResult; + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export async function generateWeeklyPost( + data: ExportData, + evo: EvolutionLiveData | null, + weekStart?: Date, +): Promise { + const now = new Date(); + const week = weekStart ?? startOfWeek(now); + + const weekMatches = matchesInWeek(data.matches, week); + const stats = computeWeekStats(weekMatches, data.bots, evo); + + const template = templateNarrative(week, stats); + const narrative = await llmNarrative(week, stats, template); + + return { + slug: weekSlug(week), + title: narrative.title, + published_at: now.toISOString(), + week_start: isoDate(week), + summary: narrative.summary, + body_html: narrative.body_html, + stats, + }; +} + +export function buildBlogIndex(posts: BlogPost[]): BlogIndex { + return { + updated_at: new Date().toISOString(), + posts: posts.sort((a, b) => b.week_start.localeCompare(a.week_start)), + }; +} + +/** + * Compute the start-of-week dates for the last N weeks. + */ +export function lastNWeekStarts(n: number, from?: Date): Date[] { + const base = startOfWeek(from ?? new Date()); + const weeks: Date[] = []; + for (let i = 0; i < n; i++) { + const d = new Date(base); + d.setUTCDate(base.getUTCDate() - i * 7); + weeks.push(d); + } + return weeks; +} diff --git a/cmd/acb-indexer/src/types.ts b/cmd/acb-indexer/src/types.ts index 5545b26..185d5a0 100644 --- a/cmd/acb-indexer/src/types.ts +++ b/cmd/acb-indexer/src/types.ts @@ -121,3 +121,76 @@ export interface MatchIndex { updated_at: string; matches: MatchSummary[]; } + +// Blog / Narrative Engine types + +export interface BlogPost { + slug: string; + title: string; + published_at: string; // ISO 8601 date + week_start: string; // ISO 8601 date (Monday of the covered week) + summary: string; // one-paragraph plain-text teaser + body_html: string; // full HTML narrative content + stats: BlogWeekStats; +} + +export interface BlogWeekStats { + matches_played: number; + top_bot: string; + top_bot_rating: number; + biggest_upset: string | null; // "BotA defeated BotB" or null + most_active_bot: string; + most_active_bot_matches: number; + island_leader: string | null; // leading evolution island +} + +export interface BlogIndex { + updated_at: string; + posts: BlogPost[]; +} + +// Evolution dashboard types (written by acb-evolver live-export) +export interface EvolutionIslandStat { + count: number; + best_fitness: number; + avg_fitness: number; + diversity: number; + promoted_count: number; +} + +export interface EvolutionGenerationEntry { + generation: number; + island: string; + evaluated_at: string; + count: number; + promoted: number; + best_fitness: number; + avg_fitness: number; +} + +export interface EvolutionLineageNode { + id: number; + parent_ids: number[]; + generation: number; + island: string; + fitness: number; + promoted: boolean; + language: string; + created_at: string; +} + +export interface EvolutionMetaSnapshot { + generation: number; + island_counts: Record; + island_best_fitness: Record; +} + +export interface EvolutionLiveData { + updated_at: string; + total_programs: number; + promoted_count: number; + islands: Record; + generation_log: EvolutionGenerationEntry[]; + lineage: EvolutionLineageNode[]; + meta_snapshots: EvolutionMetaSnapshot[]; +} diff --git a/cmd/acb-indexer/src/writer.ts b/cmd/acb-indexer/src/writer.ts index 8bf865b..7b5ca05 100644 --- a/cmd/acb-indexer/src/writer.ts +++ b/cmd/acb-indexer/src/writer.ts @@ -3,7 +3,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; -import type { LeaderboardIndex, BotDirectory, BotProfile, MatchIndex } from './types.js'; +import type { LeaderboardIndex, BotDirectory, BotProfile, MatchIndex, EvolutionLiveData } from './types.js'; export class FileWriter { private outputDir: string; @@ -20,6 +20,7 @@ export class FileWriter { this.outputDir, path.join(this.outputDir, 'bots'), path.join(this.outputDir, 'matches'), + path.join(this.outputDir, 'evolution'), ]; for (const dir of dirs) { @@ -87,6 +88,14 @@ export class FileWriter { await this.writeJson(filePath, matchIndex); } + /** + * Write evolution/live.json + */ + async writeEvolutionLive(data: EvolutionLiveData): Promise { + const filePath = path.join(this.outputDir, 'evolution', 'live.json'); + await this.writeJson(filePath, data); + } + /** * Write all index files */ @@ -95,6 +104,7 @@ export class FileWriter { botDirectory: BotDirectory; botProfiles: Map; matchIndex: MatchIndex; + evolutionLive?: EvolutionLiveData; }): Promise { await this.ensureDirectories(); @@ -103,9 +113,16 @@ export class FileWriter { await this.writeBotProfiles(data.botProfiles); await this.writeMatchIndex(data.matchIndex); + if (data.evolutionLive) { + await this.writeEvolutionLive(data.evolutionLive); + } + console.log(`\nIndex generation complete!`); console.log(` - ${data.leaderboard.entries.length} leaderboard entries`); console.log(` - ${data.botProfiles.size} bot profiles`); console.log(` - ${data.matchIndex.matches.length} matches`); + if (data.evolutionLive) { + console.log(` - ${data.evolutionLive.total_programs} evolution programs`); + } } } diff --git a/cmd/acb-wasm/botmain/gatherer/main.go b/cmd/acb-wasm/botmain/gatherer/main.go new file mode 100644 index 0000000..e18a6be --- /dev/null +++ b/cmd/acb-wasm/botmain/gatherer/main.go @@ -0,0 +1,47 @@ +//go:build js && wasm + +// gatherer.wasm implements the ACB WASM bot interface for the gatherer strategy. +// +// acbBot.init(configJSON) – initialise for a new match +// acbBot.compute_moves(stateJSON) – return movesJSON for the current turn +package main + +import ( + "encoding/json" + "math/rand" + "syscall/js" + "time" + + "github.com/aicodebattle/acb/cmd/acb-wasm/strategies" + "github.com/aicodebattle/acb/engine" +) + +var rng *rand.Rand + +func jsInit(_ js.Value, args []js.Value) interface{} { + rng = rand.New(rand.NewSource(time.Now().UnixNano())) + return map[string]interface{}{"ok": true} +} + +func jsComputeMoves(_ js.Value, args []js.Value) interface{} { + if len(args) < 1 { + return map[string]interface{}{"ok": false, "error": "stateJSON required"} + } + var state engine.VisibleState + if err := json.Unmarshal([]byte(args[0].String()), &state); err != nil { + return map[string]interface{}{"ok": false, "error": "parse: " + err.Error()} + } + bot := strategies.New("gatherer", rng) + moves, _ := bot.GetMoves(&state) + b, _ := json.Marshal(moves) + return string(b) +} + +func main() { + done := make(chan struct{}) + js.Global().Set("acbBot", js.ValueOf(map[string]interface{}{ + "init": js.FuncOf(jsInit), + "compute_moves": js.FuncOf(jsComputeMoves), + })) + <-done +} diff --git a/cmd/acb-wasm/botmain/guardian/main.go b/cmd/acb-wasm/botmain/guardian/main.go new file mode 100644 index 0000000..c29e21a --- /dev/null +++ b/cmd/acb-wasm/botmain/guardian/main.go @@ -0,0 +1,47 @@ +//go:build js && wasm + +// guardian.wasm implements the ACB WASM bot interface for the guardian strategy. +// +// acbBot.init(configJSON) – initialise for a new match +// acbBot.compute_moves(stateJSON) – return movesJSON for the current turn +package main + +import ( + "encoding/json" + "math/rand" + "syscall/js" + "time" + + "github.com/aicodebattle/acb/cmd/acb-wasm/strategies" + "github.com/aicodebattle/acb/engine" +) + +var rng *rand.Rand + +func jsInit(_ js.Value, args []js.Value) interface{} { + rng = rand.New(rand.NewSource(time.Now().UnixNano())) + return map[string]interface{}{"ok": true} +} + +func jsComputeMoves(_ js.Value, args []js.Value) interface{} { + if len(args) < 1 { + return map[string]interface{}{"ok": false, "error": "stateJSON required"} + } + var state engine.VisibleState + if err := json.Unmarshal([]byte(args[0].String()), &state); err != nil { + return map[string]interface{}{"ok": false, "error": "parse: " + err.Error()} + } + bot := strategies.New("guardian", rng) + moves, _ := bot.GetMoves(&state) + b, _ := json.Marshal(moves) + return string(b) +} + +func main() { + done := make(chan struct{}) + js.Global().Set("acbBot", js.ValueOf(map[string]interface{}{ + "init": js.FuncOf(jsInit), + "compute_moves": js.FuncOf(jsComputeMoves), + })) + <-done +} diff --git a/cmd/acb-wasm/botmain/hunter/main.go b/cmd/acb-wasm/botmain/hunter/main.go new file mode 100644 index 0000000..497944f --- /dev/null +++ b/cmd/acb-wasm/botmain/hunter/main.go @@ -0,0 +1,47 @@ +//go:build js && wasm + +// hunter.wasm implements the ACB WASM bot interface for the hunter strategy. +// +// acbBot.init(configJSON) – initialise for a new match +// acbBot.compute_moves(stateJSON) – return movesJSON for the current turn +package main + +import ( + "encoding/json" + "math/rand" + "syscall/js" + "time" + + "github.com/aicodebattle/acb/cmd/acb-wasm/strategies" + "github.com/aicodebattle/acb/engine" +) + +var rng *rand.Rand + +func jsInit(_ js.Value, args []js.Value) interface{} { + rng = rand.New(rand.NewSource(time.Now().UnixNano())) + return map[string]interface{}{"ok": true} +} + +func jsComputeMoves(_ js.Value, args []js.Value) interface{} { + if len(args) < 1 { + return map[string]interface{}{"ok": false, "error": "stateJSON required"} + } + var state engine.VisibleState + if err := json.Unmarshal([]byte(args[0].String()), &state); err != nil { + return map[string]interface{}{"ok": false, "error": "parse: " + err.Error()} + } + bot := strategies.New("hunter", rng) + moves, _ := bot.GetMoves(&state) + b, _ := json.Marshal(moves) + return string(b) +} + +func main() { + done := make(chan struct{}) + js.Global().Set("acbBot", js.ValueOf(map[string]interface{}{ + "init": js.FuncOf(jsInit), + "compute_moves": js.FuncOf(jsComputeMoves), + })) + <-done +} diff --git a/cmd/acb-wasm/botmain/random/main.go b/cmd/acb-wasm/botmain/random/main.go new file mode 100644 index 0000000..1eec727 --- /dev/null +++ b/cmd/acb-wasm/botmain/random/main.go @@ -0,0 +1,52 @@ +//go:build js && wasm + +// random.wasm – random-strategy bot implementing the ACB WASM bot interface. +// +// Exported JS object (global acbBot): +// +// acbBot.init(configJSON) – initialise; resets RNG seed +// acbBot.compute_moves(stateJSON) – returns JSON move array +package main + +import ( + "encoding/json" + "math/rand" + "syscall/js" + "time" + + "github.com/aicodebattle/acb/engine" +) + +var rng *rand.Rand + +func jsInit(_ js.Value, args []js.Value) interface{} { + rng = rand.New(rand.NewSource(time.Now().UnixNano())) + return map[string]interface{}{"ok": true} +} + +func jsComputeMoves(_ js.Value, args []js.Value) interface{} { + if len(args) < 1 { + return jsErr("stateJSON required") + } + var state engine.VisibleState + if err := json.Unmarshal([]byte(args[0].String()), &state); err != nil { + return jsErr("parse: " + err.Error()) + } + bot := engine.NewRandomBot(rng.Int63()) + moves, _ := bot.GetMoves(&state) + b, _ := json.Marshal(moves) + return string(b) +} + +func jsErr(msg string) map[string]interface{} { + return map[string]interface{}{"ok": false, "error": msg} +} + +func main() { + done := make(chan struct{}) + js.Global().Set("acbBot", js.ValueOf(map[string]interface{}{ + "init": js.FuncOf(jsInit), + "compute_moves": js.FuncOf(jsComputeMoves), + })) + <-done +} diff --git a/cmd/acb-wasm/botmain/rusher/main.go b/cmd/acb-wasm/botmain/rusher/main.go new file mode 100644 index 0000000..063f85a --- /dev/null +++ b/cmd/acb-wasm/botmain/rusher/main.go @@ -0,0 +1,47 @@ +//go:build js && wasm + +// rusher.wasm implements the ACB WASM bot interface for the rusher strategy. +// +// acbBot.init(configJSON) – initialise for a new match +// acbBot.compute_moves(stateJSON) – return movesJSON for the current turn +package main + +import ( + "encoding/json" + "math/rand" + "syscall/js" + "time" + + "github.com/aicodebattle/acb/cmd/acb-wasm/strategies" + "github.com/aicodebattle/acb/engine" +) + +var rng *rand.Rand + +func jsInit(_ js.Value, args []js.Value) interface{} { + rng = rand.New(rand.NewSource(time.Now().UnixNano())) + return map[string]interface{}{"ok": true} +} + +func jsComputeMoves(_ js.Value, args []js.Value) interface{} { + if len(args) < 1 { + return map[string]interface{}{"ok": false, "error": "stateJSON required"} + } + var state engine.VisibleState + if err := json.Unmarshal([]byte(args[0].String()), &state); err != nil { + return map[string]interface{}{"ok": false, "error": "parse: " + err.Error()} + } + bot := strategies.New("rusher", rng) + moves, _ := bot.GetMoves(&state) + b, _ := json.Marshal(moves) + return string(b) +} + +func main() { + done := make(chan struct{}) + js.Global().Set("acbBot", js.ValueOf(map[string]interface{}{ + "init": js.FuncOf(jsInit), + "compute_moves": js.FuncOf(jsComputeMoves), + })) + <-done +} diff --git a/cmd/acb-wasm/botmain/swarm/main.go b/cmd/acb-wasm/botmain/swarm/main.go new file mode 100644 index 0000000..2f079c2 --- /dev/null +++ b/cmd/acb-wasm/botmain/swarm/main.go @@ -0,0 +1,47 @@ +//go:build js && wasm + +// swarm.wasm implements the ACB WASM bot interface for the swarm strategy. +// +// acbBot.init(configJSON) – initialise for a new match +// acbBot.compute_moves(stateJSON) – return movesJSON for the current turn +package main + +import ( + "encoding/json" + "math/rand" + "syscall/js" + "time" + + "github.com/aicodebattle/acb/cmd/acb-wasm/strategies" + "github.com/aicodebattle/acb/engine" +) + +var rng *rand.Rand + +func jsInit(_ js.Value, args []js.Value) interface{} { + rng = rand.New(rand.NewSource(time.Now().UnixNano())) + return map[string]interface{}{"ok": true} +} + +func jsComputeMoves(_ js.Value, args []js.Value) interface{} { + if len(args) < 1 { + return map[string]interface{}{"ok": false, "error": "stateJSON required"} + } + var state engine.VisibleState + if err := json.Unmarshal([]byte(args[0].String()), &state); err != nil { + return map[string]interface{}{"ok": false, "error": "parse: " + err.Error()} + } + bot := strategies.New("swarm", rng) + moves, _ := bot.GetMoves(&state) + b, _ := json.Marshal(moves) + return string(b) +} + +func main() { + done := make(chan struct{}) + js.Global().Set("acbBot", js.ValueOf(map[string]interface{}{ + "init": js.FuncOf(jsInit), + "compute_moves": js.FuncOf(jsComputeMoves), + })) + <-done +} diff --git a/cmd/acb-wasm/bots.go b/cmd/acb-wasm/bots.go new file mode 100644 index 0000000..78d4e78 --- /dev/null +++ b/cmd/acb-wasm/bots.go @@ -0,0 +1,15 @@ +//go:build js && wasm + +package main + +import ( + "math/rand" + + "github.com/aicodebattle/acb/cmd/acb-wasm/strategies" + "github.com/aicodebattle/acb/engine" +) + +// newBuiltinBot creates one of the six built-in strategy bots by name. +func newBuiltinBot(name string, rng *rand.Rand) engine.BotInterface { + return strategies.New(name, rng) +} diff --git a/cmd/acb-wasm/build.sh b/cmd/acb-wasm/build.sh new file mode 100755 index 0000000..d47c8db --- /dev/null +++ b/cmd/acb-wasm/build.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Build the WASM engine and the six built-in bot WASM modules. +# +# Usage: +# ./cmd/acb-wasm/build.sh +# +# Outputs are written to web/public/wasm/: +# engine.wasm – game engine with loadState/step/runMatch API +# wasm_exec.js – Go WASM runtime shim (copied from GOROOT) +# bots/random.wasm – Random strategy +# bots/gatherer.wasm – Gatherer strategy +# bots/rusher.wasm – Rusher strategy +# bots/guardian.wasm – Guardian strategy +# bots/swarm.wasm – Swarm strategy +# bots/hunter.wasm – Hunter strategy +# +# The bot WASM files implement the ACB WASM bot interface: +# acbBot.init(configJSON) – initialise for a new match +# acbBot.compute_moves(stateJSON) – return movesJSON for the current turn +# +# Prerequisites: Go 1.21+ with WASM support. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +OUT="$REPO_ROOT/web/public/wasm" + +mkdir -p "$OUT/bots" + +echo "Building engine.wasm…" +GOOS=js GOARCH=wasm go build \ + -o "$OUT/engine.wasm" \ + ./cmd/acb-wasm/ + +echo "Copying wasm_exec.js…" +cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" "$OUT/" + +echo "Building bot WASM modules…" +for bot in random gatherer rusher guardian swarm hunter; do + echo " → bots/${bot}.wasm" + GOOS=js GOARCH=wasm go build \ + -o "$OUT/bots/${bot}.wasm" \ + "./cmd/acb-wasm/botmain/${bot}/" +done + +echo "Done. Outputs in $OUT" diff --git a/cmd/acb-wasm/main.go b/cmd/acb-wasm/main.go new file mode 100644 index 0000000..10bb182 --- /dev/null +++ b/cmd/acb-wasm/main.go @@ -0,0 +1,210 @@ +//go:build js && wasm + +// Package main is compiled with GOOS=js GOARCH=wasm to produce engine.wasm. +// It exposes three functions on the global acbEngine object: +// +// acbEngine.loadState(stateJSON) – load a serialised GameState +// acbEngine.step(movesJSON) – advance one turn; returns {state,result} +// acbEngine.runMatch(configJSON) – run a full match; returns {replay,result} +// +// Example (JavaScript): +// +// const go = new Go(); +// WebAssembly.instantiateStreaming(fetch('/wasm/engine.wasm'), go.importObject) +// .then(({instance}) => { go.run(instance); }); +// // acbEngine is now available +package main + +import ( + "encoding/json" + "math/rand" + "syscall/js" + "time" + + "github.com/aicodebattle/acb/engine" +) + +// matchSession holds a running match for turn-by-turn access. +type matchSession struct { + gs *engine.GameState + bots []engine.BotInterface + recorder *engine.ReplayWriter +} + +var session *matchSession + +// jsLoadState parses a serialised GameState JSON and stores it as the active session. +// Signature: acbEngine.loadState(stateJSON: string) => {ok:bool, error?:string} +func jsLoadState(_ js.Value, args []js.Value) interface{} { + if len(args) < 1 { + return jsErr("stateJSON argument required") + } + // For now, we expect an initialisation config rather than a full state dump. + type initRequest struct { + Config engine.Config `json:"config"` + Seed int64 `json:"seed"` + Bot1 string `json:"bot1"` // strategy name + Bot2 string `json:"bot2"` // strategy name + } + var req initRequest + if err := json.Unmarshal([]byte(args[0].String()), &req); err != nil { + return jsErr("parse error: " + err.Error()) + } + + cfg := req.Config + if cfg.Rows == 0 { + cfg = engine.DefaultConfig() + // Smaller default for in-browser matches + cfg.Rows = 30 + cfg.Cols = 30 + cfg.MaxTurns = 200 + } + + seed := req.Seed + if seed == 0 { + seed = time.Now().UnixNano() + } + rng := rand.New(rand.NewSource(seed)) + + gs := engine.NewGameState(cfg, rng) + + bot1 := newBuiltinBot(req.Bot1, rng) + bot2 := newBuiltinBot(req.Bot2, rng) + + mr := engine.NewMatchRunner(cfg, engine.WithRNG(rand.New(rand.NewSource(seed)))) + mr.AddBot(bot1, req.Bot1) + mr.AddBot(bot2, req.Bot2) + + _ = gs // session setup done via match runner below + + session = &matchSession{ + gs: gs, + bots: []engine.BotInterface{bot1, bot2}, + } + + return map[string]interface{}{"ok": true} +} + +// jsStep advances one turn. +// Signature: acbEngine.step(movesJSON: string) => {state, events, result?} +func jsStep(_ js.Value, args []js.Value) interface{} { + if session == nil { + return jsErr("no active session; call loadState first") + } + gs := session.gs + + // Parse moves from caller (if provided) + if len(args) > 0 && args[0].String() != "" { + var moves []engine.Move + if err := json.Unmarshal([]byte(args[0].String()), &moves); err != nil { + return jsErr("parse moves: " + err.Error()) + } + for _, m := range moves { + // Find bot at position and submit move + for _, b := range gs.Bots { + if b.Alive && b.Position == m.Position { + gs.Moves[b.ID] = m + } + } + } + } + + result := gs.ExecuteTurn() + + stateJSON, _ := json.Marshal(gs) + eventsJSON, _ := json.Marshal(gs.Events) + + out := map[string]interface{}{ + "state": string(stateJSON), + "events": string(eventsJSON), + "turn": gs.Turn, + } + if result != nil { + resultJSON, _ := json.Marshal(result) + out["result"] = string(resultJSON) + } + return out +} + +// jsRunMatch executes a complete match and returns the replay. +// Signature: acbEngine.runMatch(configJSON: string) => {replay, result} +func jsRunMatch(_ js.Value, args []js.Value) interface{} { + type runRequest struct { + Config engine.Config `json:"config"` + Bot1 string `json:"bot1"` + Bot2 string `json:"bot2"` + Seed int64 `json:"seed"` + } + + var req runRequest + if len(args) > 0 && args[0].String() != "" { + if err := json.Unmarshal([]byte(args[0].String()), &req); err != nil { + return jsErr("parse config: " + err.Error()) + } + } + + cfg := req.Config + if cfg.Rows == 0 { + cfg = engine.DefaultConfig() + cfg.Rows = 30 + cfg.Cols = 30 + cfg.MaxTurns = 200 + } + + seed := req.Seed + if seed == 0 { + seed = time.Now().UnixNano() + } + rng := rand.New(rand.NewSource(seed)) + + bot1Name := req.Bot1 + if bot1Name == "" { + bot1Name = "random" + } + bot2Name := req.Bot2 + if bot2Name == "" { + bot2Name = "random" + } + + mr := engine.NewMatchRunner(cfg, + engine.WithRNG(rng), + engine.WithTimeout(500*time.Millisecond), + ) + mr.AddBot(newBuiltinBot(bot1Name, rand.New(rand.NewSource(seed))), bot1Name) + mr.AddBot(newBuiltinBot(bot2Name, rand.New(rand.NewSource(seed+1))), bot2Name) + + result, replay, err := mr.Run() + if err != nil { + return jsErr("run match: " + err.Error()) + } + + replayJSON, _ := json.Marshal(replay) + resultJSON, _ := json.Marshal(result) + return map[string]interface{}{ + "replay": string(replayJSON), + "result": string(resultJSON), + } +} + +func jsErr(msg string) map[string]interface{} { + return map[string]interface{}{"ok": false, "error": msg} +} + +func main() { + done := make(chan struct{}) + + js.Global().Set("acbEngine", js.ValueOf(map[string]interface{}{ + "loadState": js.FuncOf(func(this js.Value, args []js.Value) interface{} { + return jsLoadState(this, args) + }), + "step": js.FuncOf(func(this js.Value, args []js.Value) interface{} { + return jsStep(this, args) + }), + "runMatch": js.FuncOf(func(this js.Value, args []js.Value) interface{} { + return jsRunMatch(this, args) + }), + "version": "1.0.0", + })) + + <-done +} diff --git a/cmd/acb-wasm/strategies/strategies.go b/cmd/acb-wasm/strategies/strategies.go new file mode 100644 index 0000000..af1a610 --- /dev/null +++ b/cmd/acb-wasm/strategies/strategies.go @@ -0,0 +1,301 @@ +// Package strategies provides the six built-in ACB bot strategies for use in +// WASM builds. Each strategy implements engine.BotInterface. +package strategies + +import ( + "math/rand" + + "github.com/aicodebattle/acb/engine" +) + +// New returns a BotInterface for the named strategy. +// Unknown names fall back to random. +func New(name string, rng *rand.Rand) engine.BotInterface { + switch name { + case "gatherer": + return NewGatherer(rng) + case "rusher": + return NewRusher(rng) + case "guardian": + return NewGuardian(rng) + case "swarm": + return NewSwarm(rng) + case "hunter": + return NewHunter(rng) + default: + return engine.NewRandomBot(rng.Int63()) + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// GathererBot – energy-focused, avoids combat +// ──────────────────────────────────────────────────────────────────────────── + +type Gatherer struct{ rng *rand.Rand } + +func NewGatherer(rng *rand.Rand) *Gatherer { return &Gatherer{rng: rng} } + +func (b *Gatherer) GetMoves(state *engine.VisibleState) ([]engine.Move, error) { + myID := state.You.ID + energySet := posSet(state.Energy) + enemySet := enemyPositions(state.Bots, myID) + var moves []engine.Move + for _, bot := range state.Bots { + if bot.Owner != myID { + continue + } + dir := fleeDir(bot.Position, enemySet, state.Config) + if dir == engine.DirNone { + dir = towardNearest(bot.Position, energySet, state.Config) + } + if dir == engine.DirNone { + dir = randDir(b.rng) + } + moves = append(moves, engine.Move{Position: bot.Position, Direction: dir}) + } + return moves, nil +} + +// ──────────────────────────────────────────────────────────────────────────── +// RusherBot – attacks enemy cores and bots aggressively +// ──────────────────────────────────────────────────────────────────────────── + +type Rusher struct{ rng *rand.Rand } + +func NewRusher(rng *rand.Rand) *Rusher { return &Rusher{rng: rng} } + +func (b *Rusher) GetMoves(state *engine.VisibleState) ([]engine.Move, error) { + myID := state.You.ID + coreSet := make(map[engine.Position]bool) + for _, c := range state.Cores { + if c.Owner != myID && c.Active { + coreSet[c.Position] = true + } + } + enemySet := enemyPositions(state.Bots, myID) + var moves []engine.Move + for _, bot := range state.Bots { + if bot.Owner != myID { + continue + } + var dir engine.Direction + if len(coreSet) > 0 { + dir = towardNearest(bot.Position, coreSet, state.Config) + } else { + dir = towardNearest(bot.Position, enemySet, state.Config) + } + if dir == engine.DirNone { + dir = randDir(b.rng) + } + moves = append(moves, engine.Move{Position: bot.Position, Direction: dir}) + } + return moves, nil +} + +// ──────────────────────────────────────────────────────────────────────────── +// GuardianBot – defends own cores +// ──────────────────────────────────────────────────────────────────────────── + +type Guardian struct{ rng *rand.Rand } + +func NewGuardian(rng *rand.Rand) *Guardian { return &Guardian{rng: rng} } + +func (b *Guardian) GetMoves(state *engine.VisibleState) ([]engine.Move, error) { + myID := state.You.ID + myCoreSet := make(map[engine.Position]bool) + for _, c := range state.Cores { + if c.Owner == myID && c.Active { + myCoreSet[c.Position] = true + } + } + enemySet := enemyPositions(state.Bots, myID) + var moves []engine.Move + for _, bot := range state.Bots { + if bot.Owner != myID { + continue + } + var dir engine.Direction + if isNear(bot.Position, enemySet, state.Config, state.Config.AttackRadius2+4) { + dir = towardNearest(bot.Position, enemySet, state.Config) + } else { + dir = towardNearest(bot.Position, myCoreSet, state.Config) + } + if dir == engine.DirNone { + dir = randDir(b.rng) + } + moves = append(moves, engine.Move{Position: bot.Position, Direction: dir}) + } + return moves, nil +} + +// ──────────────────────────────────────────────────────────────────────────── +// SwarmBot – spreads to maximise map coverage +// ──────────────────────────────────────────────────────────────────────────── + +type Swarm struct{ rng *rand.Rand } + +func NewSwarm(rng *rand.Rand) *Swarm { return &Swarm{rng: rng} } + +func (b *Swarm) GetMoves(state *engine.VisibleState) ([]engine.Move, error) { + myID := state.You.ID + dirs := []engine.Direction{engine.DirN, engine.DirE, engine.DirS, engine.DirW} + var moves []engine.Move + for _, bot := range state.Bots { + if bot.Owner != myID { + continue + } + best, bestScore := engine.DirNone, -1 + for _, d := range dirs { + np := applyDir(bot.Position, d, state.Config) + score := 0 + for _, other := range state.Bots { + if other.Owner == myID { + score += dist2(np, other.Position, state.Config) + } + } + if best == engine.DirNone || score > bestScore { + bestScore = score + best = d + } + } + moves = append(moves, engine.Move{Position: bot.Position, Direction: best}) + } + return moves, nil +} + +// ──────────────────────────────────────────────────────────────────────────── +// HunterBot – hunts nearest enemy bot +// ──────────────────────────────────────────────────────────────────────────── + +type Hunter struct{ rng *rand.Rand } + +func NewHunter(rng *rand.Rand) *Hunter { return &Hunter{rng: rng} } + +func (b *Hunter) GetMoves(state *engine.VisibleState) ([]engine.Move, error) { + myID := state.You.ID + enemySet := enemyPositions(state.Bots, myID) + energySet := posSet(state.Energy) + var moves []engine.Move + for _, bot := range state.Bots { + if bot.Owner != myID { + continue + } + var dir engine.Direction + if len(enemySet) > 0 { + dir = towardNearest(bot.Position, enemySet, state.Config) + } else { + dir = towardNearest(bot.Position, energySet, state.Config) + } + if dir == engine.DirNone { + dir = randDir(b.rng) + } + moves = append(moves, engine.Move{Position: bot.Position, Direction: dir}) + } + return moves, nil +} + +// ──────────────────────────────────────────────────────────────────────────── +// Helpers (unexported) +// ──────────────────────────────────────────────────────────────────────────── + +var allDirs = []engine.Direction{engine.DirN, engine.DirE, engine.DirS, engine.DirW} + +func randDir(rng *rand.Rand) engine.Direction { return allDirs[rng.Intn(4)] } + +func posSet(positions []engine.Position) map[engine.Position]bool { + m := make(map[engine.Position]bool, len(positions)) + for _, p := range positions { + m[p] = true + } + return m +} + +func enemyPositions(bots []engine.VisibleBot, myID int) map[engine.Position]bool { + m := make(map[engine.Position]bool) + for _, b := range bots { + if b.Owner != myID { + m[b.Position] = true + } + } + return m +} + +func applyDir(p engine.Position, d engine.Direction, cfg engine.Config) engine.Position { + dr, dc := d.Delta() + row := ((p.Row+dr)%cfg.Rows + cfg.Rows) % cfg.Rows + col := ((p.Col+dc)%cfg.Cols + cfg.Cols) % cfg.Cols + return engine.Position{Row: row, Col: col} +} + +func dist2(a, b engine.Position, cfg engine.Config) int { + dr := a.Row - b.Row + if dr < 0 { + dr = -dr + } + if dr > cfg.Rows/2 { + dr = cfg.Rows - dr + } + dc := a.Col - b.Col + if dc < 0 { + dc = -dc + } + if dc > cfg.Cols/2 { + dc = cfg.Cols - dc + } + return dr*dr + dc*dc +} + +func towardNearest(from engine.Position, targets map[engine.Position]bool, cfg engine.Config) engine.Direction { + if len(targets) == 0 { + return engine.DirNone + } + best, bestD := engine.DirNone, 1<<31-1 + for _, d := range allDirs { + np := applyDir(from, d, cfg) + for t := range targets { + if d2 := dist2(np, t, cfg); d2 < bestD { + bestD = d2 + best = d + } + } + } + return best +} + +func fleeDir(from engine.Position, enemies map[engine.Position]bool, cfg engine.Config) engine.Direction { + thr := cfg.AttackRadius2 + 4 + close := false + for e := range enemies { + if dist2(from, e, cfg) <= thr { + close = true + break + } + } + if !close { + return engine.DirNone + } + best, bestD := engine.DirNone, -1 + for _, d := range allDirs { + np := applyDir(from, d, cfg) + minD := 1<<31 - 1 + for e := range enemies { + if d2 := dist2(np, e, cfg); d2 < minD { + minD = d2 + } + } + if minD > bestD { + bestD = minD + best = d + } + } + return best +} + +func isNear(from engine.Position, targets map[engine.Position]bool, cfg engine.Config, r2 int) bool { + for t := range targets { + if dist2(from, t, cfg) <= r2 { + return true + } + } + return false +} diff --git a/docs/plan/plan.md b/docs/plan/plan.md index e31c43c..0908dd0 100644 --- a/docs/plan/plan.md +++ b/docs/plan/plan.md @@ -16,80 +16,96 @@ implementations for the HTTP protocol. ## 2. System Architecture -The platform uses a **hybrid Cloudflare + Kubernetes** architecture. The web -tier lives on Cloudflare (Pages for the static SPA, R2 for replay files and -large data), while all compute runs in the **apexalgo-iad** Kubernetes cluster -inside a dedicated `ai-code-battle` namespace. The cluster provides PostgreSQL -(CNPG), Valkey (Redis-compatible), Traefik ingress, cert-manager TLS, -ArgoCD for GitOps, Argo Workflows for CI, and SATA (Cinder CSI) block storage. +The platform uses a **static-first** architecture. The public-facing product +is a **Cloudflare Pages** static site — all data visitors see (leaderboards, +match history, bot profiles, replays) is pre-computed JSON served from the CDN. +All compute runs in the **apexalgo-iad** Kubernetes cluster (Rackspace Spot), +which acts as a **match factory**: it runs battles, generates replays, and +periodically publishes the updated site to Pages. -### Cloudflare (Web Tier) +Replay files use **tiered storage**: Backblaze B2 is the permanent cold +archive (all replays, forever), and Cloudflare R2 is a warm cache for recent +replays (free tier, ≤10 GB). The browser fetches replays from R2; old replays +that have aged out of R2 fall back to B2 (free egress via Cloudflare Bandwidth +Alliance). -- **Cloudflare Pages**: Hosts the static SPA shell and pre-computed JSON data - files (`data/leaderboard.json`, `data/bots/*.json`, etc.). Updated every - ~90 minutes by the K8s index builder via `wrangler pages deploy`. Global - CDN, zero-config TLS, instant cache invalidation on deploy. -- **Cloudflare R2**: Stores replay files (potentially hundreds of thousands — - well beyond Pages' 20K file limit), per-match metadata JSON, evolution - `live.json`, thumbnails, and bot card images. Also serves as the data bus - between K8s components (workers upload, browser downloads). Accessed via - S3-compatible API from K8s, served to browsers via public R2 bucket URL or - custom domain (`r2.aicodebattle.com`). +### Cloudflare (Static Tier) + +- **Cloudflare Pages** (`ai-code-battle.pages.dev`): Hosts the static SPA + shell and **all** pre-computed JSON data — leaderboards, bot profiles, match + indexes, series, seasons, evolution data, blog posts. Updated every ~90 + minutes by the K8s index builder via `wrangler pages deploy`. Global CDN, + zero-config TLS, instant cache invalidation on deploy. +- **Cloudflare R2** (warm replay cache): Stores recent replay files and + per-match metadata — the subset of data too numerous for Pages' 20K file + limit and too hot for B2-only serving. Capped at ≤10 GB to stay within + the free tier. The index builder manages the R2 lifecycle: promotes recent + replays from B2, prunes old ones when approaching the cap. + +### Backblaze B2 (Cold Archive) + +- **B2 bucket**: Permanent archive for **all** replay files and match + metadata. Match workers upload directly to B2 after each match. B2 is the + source of truth for replay data — R2 is just a CDN cache in front of it. + Free egress to browsers via Cloudflare Bandwidth Alliance (B2 + Cloudflare + = zero egress fees). S3-compatible API. ### apexalgo-iad (Compute Tier) All backend compute runs in the `ai-code-battle` namespace: -- **Go API Deployment**: Registration, job coordination, matchmaking, health - checks, stale job reaping. Exposed publicly at `api.aicodebattle.com` via - Traefik IngressRoute. Connects to PostgreSQL and Valkey. +- **Matchmaker Deployment**: Internal scheduler. Queries active bots from + PostgreSQL, computes pairings, enqueues job IDs into Valkey. Also handles + health checking and stale job reaping. No external exposure. - **PostgreSQL (CNPG)**: Source of truth for all structured data — bots, matches, jobs, ratings, predictions, series, seasons. -- **Valkey**: Job queue for match jobs, ephemeral caching, pub/sub. -- **Match Worker Deployment**: Dequeues jobs from Valkey, runs matches, - uploads replay JSON to R2 (via S3-compatible API), records results in - PostgreSQL via the Go API. +- **Valkey**: Job queue for match jobs, ephemeral caching. +- **Match Worker Deployment**: Dequeues jobs from Valkey (BRPOP), runs + matches, uploads replay JSON to B2 (cold archive), writes results to + PostgreSQL. - **Strategy Bot Deployments** (x6): Built-in bots as HTTP servers on cluster-internal Services. - **Evolved Bot Deployments** (0-50): LLM-generated bots, same pattern. - **Evolver Deployment**: LLM evolution pipeline. Reads match data from PostgreSQL, generates candidates, tests them, deploys successful bots as - new K8s Deployments. Writes `evolution/live.json` to R2. Self-restarts + new K8s Deployments. Writes evolution metadata to PostgreSQL. Self-restarts every 4h. - **Index Builder Deployment**: Sleep-loop (15 min cycle). Reads PostgreSQL, generates all JSON index files, deploys them to Cloudflare Pages via - `wrangler pages deploy`. Also writes per-match data to R2. Handles weekly - replay pruning on R2. Self-restarts every 4h. + `wrangler pages deploy`. Manages R2 warm cache (copies recent replays + from B2, prunes old replays to stay within free tier). Self-restarts + every 4h. + +### Go API (deferred) + +A public Go API at `api.aicodebattle.com` is planned for social features +(predictions, commenting, voting) and third-party bot registration. This is +**not required for the core match loop** — the v1 system is fully static. +The API will be added when interactive features are needed. ### Data Architecture -Data is split between Cloudflare and K8s by access pattern: +Data is split across three tiers by access pattern and volume: **Cloudflare Pages** (SPA + pre-computed indexes, deployed by index builder): ``` -Pages project (aicodebattle.com): -├── index.html, leaderboard.html, matches.html, ... (SPA routes) -├── js/ (bundled TypeScript application) -│ ├── app.js (SPA router, data fetching) -│ ├── replay-viewer.js (Canvas replay renderer) -│ ├── sandbox.js (WASM sandbox orchestrator) -│ └── charts.js (win probability, meta charts) -├── css/ (stylesheets) -├── wasm/ (game engine + built-in bot WASMs) -│ ├── engine.wasm -│ ├── gatherer.wasm -│ ├── rusher.wasm -│ └── ... -├── docs/ (protocol spec, replay format, data paths, guides) -├── img/ (logos, icons, UI assets) -├── embed.html (lightweight embeddable replay player) -├── data/ (pre-computed JSON indexes, rebuilt every ~15 min) +Pages project (ai-code-battle.pages.dev): +├── index.html, app.html, ... (SPA shell) +├── js/ (bundled TypeScript application) +│ ├── app.js (SPA router, data fetching) +│ ├── replay-viewer.js (Canvas replay renderer) +│ └── charts.js (win probability, meta charts) +├── css/ (stylesheets) +├── docs/ (protocol spec, replay format, guides) +├── img/ (logos, icons, UI assets) +├── embed.html (lightweight embeddable replay player) +├── data/ (pre-computed JSON indexes, rebuilt every ~15 min) │ ├── leaderboard.json │ ├── bots/ │ │ ├── index.json │ │ └── {bot_id}.json │ ├── matches/ -│ │ └── index.json (recent matches, paginated) +│ │ └── index.json (recent matches, paginated) │ ├── series/ │ │ ├── index.json │ │ └── {series_id}.json @@ -98,9 +114,6 @@ Pages project (aicodebattle.com): │ │ └── {season_id}.json │ ├── playlists/ │ │ └── {slug}.json -│ ├── predictions/ -│ │ ├── leaderboard.json -│ │ └── open.json │ ├── meta/ │ │ ├── archetypes.json │ │ └── rivalries.json @@ -115,48 +128,55 @@ Pages project (aicodebattle.com): └── {map_id}.json ``` -**Cloudflare R2** (replays + large/dynamic data, too many files for Pages): +**Cloudflare R2** (warm replay cache, free tier ≤10 GB): ``` -R2 bucket (r2.aicodebattle.com): +R2 bucket: ├── replays/ -│ └── {match_id}.json.gz (full replay files) +│ └── {match_id}.json.gz (recent replay files, promoted from B2) ├── matches/ -│ └── {match_id}.json (individual match metadata) -├── evolution/ -│ └── live.json (real-time observatory feed, updated by evolver) +│ └── {match_id}.json (recent per-match metadata) ├── thumbnails/ -│ └── {match_id}.png (auto-generated match thumbnails) +│ └── {match_id}.png (auto-generated match thumbnails) └── cards/ - └── {bot_id}.png (bot profile card images) + └── {bot_id}.png (bot profile card images) +``` + +**Backblaze B2** (cold archive, permanent): +``` +B2 bucket: +├── replays/ +│ └── {match_id}.json.gz (ALL replay files, forever) +├── matches/ +│ └── {match_id}.json (ALL per-match metadata) +├── thumbnails/ +│ └── {match_id}.png (ALL thumbnails) +└── cards/ + └── {bot_id}.png (ALL bot card images) ``` **Data loading pattern in the SPA:** ```js // SPA shell + index data from Cloudflare Pages (same origin) -const PAGES = 'https://aicodebattle.com' -// Replays + per-match data from R2 -const R2 = 'https://r2.aicodebattle.com' -// Dynamic API from K8s (registration, predictions) -const API = 'https://api.aicodebattle.com' +const PAGES = '' // relative — same origin as the SPA +// Replays from R2 warm cache (recent), B2 cold archive (old) +const R2 = 'https://r2.aicodebattle.com' // or R2 public URL +const B2 = 'https://b2.aicodebattle.com' // B2 via Cloudflare CDN -// Leaderboard page loads (pre-computed JSON on Pages, rebuilt every ~15 min): +// Leaderboard, bot profiles, match indexes — all from Pages (same origin): const lb = await fetch(`${PAGES}/data/leaderboard.json`).then(r => r.json()) -// Replay viewer loads (uploaded to R2 by match workers): -const replay = await fetch(`${R2}/replays/${matchId}.json.gz`) +// Replay viewer — try R2 warm cache first, fall back to B2 cold archive: +async function fetchReplay(matchId) { + const r2 = await fetch(`${R2}/replays/${matchId}.json.gz`) + if (r2.ok) return r2 + return fetch(`${B2}/replays/${matchId}.json.gz`) // cold fallback +} -// Match metadata (uploaded to R2 by match workers): -const meta = await fetch(`${R2}/matches/${matchId}.json`).then(r => r.json()) - -// Evolution observatory live feed (updated by evolver, on R2): -const live = await fetch(`${R2}/evolution/live.json`).then(r => r.json()) - -// Evolution lineage (rebuilt by index builder, on Pages): -const lineage = await fetch(`${PAGES}/data/evolution/lineage.json`).then(r => r.json()) - -// Registration (dynamic API on K8s): -const result = await fetch(`${API}/api/register`, { method: 'POST', body: ... }) +// Match metadata — same R2-then-B2 pattern: +const meta = await fetch(`${R2}/matches/${matchId}.json`) + .then(r => r.ok ? r : fetch(`${B2}/matches/${matchId}.json`)) + .then(r => r.json()) ``` **Cache behavior:** @@ -164,100 +184,133 @@ const result = await fetch(`${API}/api/register`, { method: 'POST', body: ... }) - **Pages assets**: Cloudflare Pages handles caching automatically. Deploys via `wrangler pages deploy` invalidate the cache globally. Index data is at most ~90 minutes stale (the index builder's cycle time). -- **R2 objects**: Served with appropriate `Cache-Control` headers set at - upload time: +- **R2 objects** (warm cache): Served with appropriate `Cache-Control` headers: - `replays/*.json.gz`: `immutable, max-age=31536000` (content-addressed) - `matches/*.json`: `immutable, max-age=31536000` (content-addressed) - - `evolution/live.json`: `max-age=10` (updated frequently by evolver) - `thumbnails/`, `cards/`: `max-age=86400` (regenerated rarely) +- **B2 objects** (cold archive): Same cache headers. B2 egress through + Cloudflare Bandwidth Alliance = zero egress fees. Cloudflare CDN caches + B2 responses, so frequently accessed cold replays still perform well. + +**Tiered storage lifecycle:** + +1. Match worker completes a match → uploads `replays/{match_id}.json.gz` + and `matches/{match_id}.json` to **B2** (cold archive, permanent) +2. Worker writes match result to **PostgreSQL** (scores, ratings, metadata) +3. Index builder (every ~15 min) reads new results from PostgreSQL, rebuilds + all JSON index files, deploys to **Pages** via `wrangler pages deploy` +4. Index builder promotes recent replays from B2 to **R2** (warm cache) +5. Index builder prunes oldest replays from R2 when approaching 10 GB cap +6. Browser loads SPA + indexes from Pages, fetches replays from R2 (warm) + with B2 fallback (cold) + +**Storage budget:** + +- **R2 (warm cache)**: ≤10 GB free tier. At ~50 KB/replay (gzipped), holds + ~200K replays. At 60 matches/hour, that's ~139 days of warm replays. + Class A writes: index builder promotes ~1,440 replays/day = ~43K/month + (well within 1M/month free tier). +- **B2 (cold archive)**: First 10 GB free, $0.006/GB/month after. At 60 + matches/hour, ~2.2 GB/month. Year one: ~26 GB = ~$0.10/month. + Free egress via Cloudflare Bandwidth Alliance. +- **Pages**: 20K file limit per deployment. Only SPA + JSON indexes — well + within limits (replays are on R2/B2, not Pages). ``` -┌────────── Cloudflare (web tier) ──────────────────────┐ -│ │ -│ ┌─────────────────────┐ ┌────────────────────────┐ │ -│ │ Cloudflare Pages │ │ Cloudflare R2 │ │ -│ │ aicodebattle.com │ │ r2.aicodebattle.com │ │ -│ │ │ │ │ │ -│ │ SPA shell (HTML/ │ │ replays/*.json.gz │ │ -│ │ JS/CSS/WASM) │ │ matches/*.json │ │ -│ │ data/*.json indexes │ │ evolution/live.json │ │ -│ │ maps/*.json │ │ thumbnails/*.png │ │ -│ │ docs/, img/ │ │ cards/*.png │ │ -│ └─────────────────────┘ └────────────────────────┘ │ -│ ▲ wrangler deploy ▲ S3-compatible PUT │ -└────────┼─────────────────────────┼──────────────────────┘ +┌────────── Cloudflare ──────────────────────────────────┐ +│ │ +│ ┌─────────────────────┐ ┌─────────────────────────┐ │ +│ │ Cloudflare Pages │ │ Cloudflare R2 │ │ +│ │ (static site) │ │ (warm replay cache) │ │ +│ │ │ │ │ │ +│ │ SPA shell (HTML/ │ │ replays/*.json.gz │ │ +│ │ JS/CSS) │ │ matches/*.json │ │ +│ │ data/*.json indexes │ │ thumbnails/*.png │ │ +│ │ maps/*.json │ │ cards/*.png │ │ +│ │ docs/, img/ │ │ ≤10 GB free tier │ │ +│ └─────────────────────┘ └─────────────────────────┘ │ +│ ▲ wrangler deploy ▲ S3 PUT (promote) │ +└────────┼─────────────────────────┼───────────────────────┘ │ │ -┌────────┼─────────────────────────┼──────────────────────┐ -│ │ apexalgo-iad cluster — ai-code-battle ns │ -│ │ │ │ -│ ┌─────┴──────────────────┐ │ │ -│ │ Index Builder Dep. │ │ │ -│ │ Reads PostgreSQL, │ │ │ -│ │ generates JSON indexes,│ │ │ -│ │ deploys to Pages │──────┘ (also writes to R2) │ -│ └─────────────────────────┘ │ -│ │ -│ ┌─────────────────────┐ ┌──────────────────────────┐ │ -│ │ Traefik Ingress │ │ cert-manager │ │ -│ │ api.aicodebattle.com│ │ TLS certificates │ │ -│ └────────┬────────────┘ └──────────────────────────┘ │ -│ │ │ -│ ┌────────▼────────────┐ │ -│ │ Go API Deployment │ │ -│ │ Registration, job │ │ -│ │ coord, matchmaking, │ │ -│ │ health, reaper │ │ -│ └────────┬────────────┘ │ -│ │ │ -│ ┌────────▼───────────────────────────────────────────┐ │ -│ │ CNPG PostgreSQL (cnpg-apexalgo) │ │ -│ │ bots, matches, jobs, ratings, etc. │ │ -│ └────────────────────────────────────────────────────┘ │ -│ │ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ Valkey (Redis-compatible) │ │ -│ │ Job queue, caching, pub/sub │ │ -│ └────────────────────────────────────────────────────┘ │ -│ │ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ Match Workers (Deployment, 1-10 pods) │ │ -│ │ Run matches, upload replays to R2, POST to API │ │ -│ └────────────────────────────────────────────────────┘ │ -│ │ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ Bot Containers (Deployments) │ │ -│ │ Strategy (×6) + Evolved (0–50) │ │ -│ └────────────────────────────────────────────────────┘ │ -│ │ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ Evolver (Deployment) │ │ -│ │ LLM pipeline, writes live.json to R2 │ │ -│ └────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ ArgoCD — syncs K8s manifests from git │ │ -│ │ Argo Workflows — CI builds, image pushes │ │ -│ │ Argo Events — GitHub webhook triggers │ │ -│ └─────────────────────────────────────────────────┘ │ -└───────────────────────────────────────────────────────────┘ +┌────────┼─────────────────────────┼───────────────────────┐ +│ │ apexalgo-iad cluster — ai-code-battle ns │ +│ │ │ │ +│ ┌─────┴──────────────────┐ │ │ +│ │ Index Builder Dep. │ │ │ +│ │ Reads PostgreSQL, │──────┘ │ +│ │ generates JSON indexes,│ │ +│ │ deploys to Pages, │ │ +│ │ promotes replays to R2,│ │ +│ │ prunes R2 warm cache │ │ +│ └─────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Matchmaker Dep. (internal only — no ingress) │ │ +│ │ Pairings, job enqueue, health check, stale reaper │ │ +│ └────────┬─────────────────────────────────────────────┘ │ +│ │ │ +│ ┌────────▼──────────────────────────────────────────────┐ │ +│ │ CNPG PostgreSQL (cnpg-apexalgo) │ │ +│ │ bots, matches, jobs, ratings, etc. │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Valkey (Redis-compatible) │ │ +│ │ Job queue (acb:jobs:pending) │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Match Workers (Deployment, 1-10 pods) │ │ +│ │ BRPOP from Valkey, run matches, upload replays to B2, │ │ +│ │ write results to PostgreSQL │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌────────────────────────────────────────────────┼───────┐ │ +│ │ Bot Containers (Deployments) │ │ │ +│ │ Strategy (×6) + Evolved (0–50) │ │ │ +│ └────────────────────────────────────────────────┼───────┘ │ +│ │ │ +│ ┌────────────────────────────────────────────────┼───────┐ │ +│ │ Evolver (Deployment) │ │ │ +│ │ LLM pipeline, writes evolution data to PG │ │ │ +│ └────────────────────────────────────────────────┼───────┘ │ +│ │ │ +│ ┌─────────────────────────────────────────────┐ │ │ +│ │ ArgoCD — syncs K8s manifests from git │ │ │ +│ │ Argo Workflows — CI builds, image pushes │ │ │ +│ └─────────────────────────────────────────────┘ │ │ +│ ▼ │ +└───────────────────────────────────────────────────┼──────────┘ + │ + ┌─────────────▼──────────┐ + │ Backblaze B2 │ + │ (cold replay archive) │ + │ │ + │ replays/*.json.gz │ + │ matches/*.json │ + │ thumbnails/*.png │ + │ cards/*.png │ + │ ALL data, forever │ + └─────────────────────────┘ ``` ### Component Summary | Component | Where | Role | |-----------|-------|------| -| **Cloudflare Pages** | Cloudflare | Hosts the static SPA (HTML/JS/CSS/WASM) and pre-computed JSON index files. Updated every ~90 min by the index builder via `wrangler pages deploy`. Global CDN with automatic cache invalidation. | -| **Cloudflare R2** | Cloudflare | Stores replay files, per-match metadata, evolution live feed, thumbnails, and bot cards. Accessed via S3-compatible API from K8s, served to browsers via public bucket URL. | -| **Go API** | Deployment (ai-code-battle ns) | Registration, job coordination, matchmaking, health checks, stale job reaping. Exposed at `api.aicodebattle.com` via Traefik IngressRoute. | -| **PostgreSQL** | CNPG cluster (cnpg ns, `cnpg-apexalgo`) | Relational database — bot registry, match queue, ratings, results, predictions, series, seasons. Source of truth. | -| **Valkey** | StatefulSet (valkey ns) | Job queue (match jobs, evolution tasks), ephemeral caching, pub/sub for live status updates. | -| **Match Workers** | Deployment (ai-code-battle ns) | Stateless match execution — dequeue job from Valkey, run simulation, upload replay to R2, record result in PostgreSQL. | +| **Cloudflare Pages** | Cloudflare | Static site: SPA (HTML/JS/CSS) and pre-computed JSON index files. Updated every ~90 min by the index builder via `wrangler pages deploy`. Global CDN with automatic cache invalidation. | +| **Cloudflare R2** | Cloudflare | Warm replay cache: recent replays, match metadata, thumbnails, bot cards. Free tier ≤10 GB. Managed by the index builder (promote from B2, prune when approaching cap). | +| **Backblaze B2** | Backblaze | Cold archive: ALL replays and match data, permanently. Workers upload directly after each match. Free egress via Cloudflare Bandwidth Alliance. | +| **Matchmaker** | Deployment (ai-code-battle ns) | Internal scheduler: computes pairings, enqueues jobs to Valkey, health checks bots, reaps stale jobs. No external exposure. | +| **PostgreSQL** | CNPG cluster (cnpg ns, `cnpg-apexalgo`) | Relational database — bot registry, match queue, ratings, results, series, seasons. Source of truth for structured data. | +| **Valkey** | Cluster service | Job queue (`acb:jobs:pending`), ephemeral caching. | +| **Match Workers** | Deployment (ai-code-battle ns) | Stateless match execution — BRPOP from Valkey, run simulation, upload replay to B2, write result to PostgreSQL. | | **Bot Containers** | Deployments + Services (ai-code-battle ns) | Strategy bots (x6) + evolved bots (0-50) — HTTP servers called by workers during matches via cluster-internal Service DNS. | -| **Evolver** | Deployment (ai-code-battle ns) | Evolution pipeline — reads lineage/meta from PostgreSQL, generates candidates, writes `evolution/live.json` to R2. | -| **Index Builder** | Deployment (ai-code-battle ns) | Sleep-loop (15 min cycle). Reads PostgreSQL, generates JSON indexes, deploys to Pages via `wrangler pages deploy`. Writes data to R2. Weekly replay pruning on R2. Self-restarts every 4h. | -| **Traefik** | Cluster ingress controller | Routes `api.aicodebattle.com` to the Go API via IngressRoute CRD. TLS via cert-manager. | +| **Evolver** | Deployment (ai-code-battle ns) | Evolution pipeline — reads lineage/meta from PostgreSQL, generates candidates, writes evolution data to PostgreSQL. | +| **Index Builder** | Deployment (ai-code-battle ns) | Sleep-loop (15 min cycle). Reads PostgreSQL, generates JSON indexes, deploys to Pages. Promotes recent replays from B2 to R2 warm cache. Prunes R2 to stay within free tier. Self-restarts every 4h. | +| **Go API** | Deferred | Social features (predictions, comments, voting) and third-party bot registration. Not required for v1. | | **ArgoCD** | Cluster (argocd ns) | GitOps: syncs all K8s manifests from git. All deployments are declarative. | -| **Argo Workflows** | Cluster (argo ns) | CI pipelines: builds container images, pushes to Forgejo registry, builds static site for Pages deployment. | +| **Argo Workflows** | Cluster (argo ns) | CI pipelines: builds container images, pushes to Forgejo registry, builds static site. | --- @@ -1067,20 +1120,29 @@ encoding — only recording events that changed from the previous turn. ### 7.2 Storage -Replays and per-match data are stored in **Cloudflare R2**. Pre-computed JSON -index files are deployed to **Cloudflare Pages** by the index builder. No -PersistentVolumes are used for web-facing data. +Replays use **tiered storage**: Backblaze B2 is the permanent cold archive +(all replays, forever), and Cloudflare R2 is a warm cache for recent replays +(free tier, ≤10 GB). Pre-computed JSON index files are deployed to +**Cloudflare Pages** by the index builder. No PersistentVolumes are used for +web-facing data. -**R2 data layout** (served at `r2.aicodebattle.com`): +**B2 data layout** (cold archive — all data, permanently): ``` -replays/{match_id}.json.gz # individual replay files -matches/{match_id}.json # per-match metadata (participants, scores) -evolution/live.json # real-time evolution observatory feed -thumbnails/{match_id}.png # auto-generated match thumbnails +replays/{match_id}.json.gz # ALL replay files +matches/{match_id}.json # ALL per-match metadata +thumbnails/{match_id}.png # ALL match thumbnails +cards/{bot_id}.png # ALL bot profile card images +``` + +**R2 data layout** (warm cache — recent subset, ≤10 GB): +``` +replays/{match_id}.json.gz # recent replay files (promoted from B2) +matches/{match_id}.json # recent per-match metadata +thumbnails/{match_id}.png # recent match thumbnails cards/{bot_id}.png # bot profile card images ``` -**Pages data layout** (served at `aicodebattle.com`): +**Pages data layout** (static site): ``` data/leaderboard.json # current leaderboard snapshot data/bots/index.json # bot directory @@ -1098,41 +1160,40 @@ maps/{map_id}.json # map definitions ``` **How data flows:** -1. Match worker completes a match -> uploads `replays/{match_id}.json.gz` - and `matches/{match_id}.json` to R2 (via S3-compatible API) -2. Worker POSTs small result metadata to the Go API - (`POST api.aicodebattle.com/api/jobs/{id}/result`) -3. Go API writes match result to PostgreSQL, updates ratings -4. Index builder Deployment (every ~15 min) reads new results from PostgreSQL, - rebuilds all JSON index files (`data/leaderboard.json`, - `data/bots/*.json`, `data/matches/index.json`, playlists, series, - seasons, blog), and deploys them to Cloudflare Pages via - `wrangler pages deploy` -5. Browser loads SPA from Pages, fetches replays from R2, calls Go API for - dynamic operations (registration, predictions) +1. Match worker completes a match → uploads `replays/{match_id}.json.gz` + and `matches/{match_id}.json` to **B2** (cold archive, via S3-compatible + API) +2. Worker writes match result to **PostgreSQL** (scores, ratings, metadata) +3. Index builder (every ~15 min) reads new results from PostgreSQL, rebuilds + all JSON index files, deploys to **Pages** via `wrangler pages deploy` +4. Index builder promotes recent replays from **B2 to R2** (warm cache) +5. Index builder prunes oldest replays from **R2** when approaching 10 GB +6. Browser loads SPA + indexes from Pages, fetches replays from R2 (warm) + with B2 fallback (cold) -**Retention and pruning:** -- Match metadata in PostgreSQL is retained indefinitely (rows are small) -- Replays on R2 are pruned on an age basis: replays older than 90 days - are deleted by the index builder's weekly pruning cycle -- **Exemptions from pruning:** replays referenced by playlists ("Closest +**Tiered retention:** +- **B2 (cold)**: All replays retained permanently. No pruning. The canonical + archive. +- **R2 (warm)**: Recent replays, capped at ≤10 GB. The index builder prunes + by age when approaching the cap — oldest replays are removed from R2 + (they remain in B2). +- **Exemptions from R2 pruning**: Replays referenced by playlists ("Closest Finishes", "Biggest Upsets", etc.), rivalry pages, series, or season - archives are exempt. The pruning job checks the exemption list from - PostgreSQL before deleting. -- At 60 matches/hour x 24h x 90 days = ~130,000 replay files at steady - state. At ~50 KB average per gzipped replay, that's ~6.5 GB. -- The pruning cycle runs weekly within the index builder: lists objects in - R2's `replays/` prefix older than 90 days, queries PostgreSQL for exempt - match IDs, deletes non-exempt objects via S3 DeleteObjects API + archives are kept warm longer. The pruning job checks the exemption list + from PostgreSQL before removing from R2. +- **PostgreSQL**: Match metadata retained indefinitely (rows are small). - Index files are append-with-rotation: `index.json` holds the last 1000; - older pages at `index-{page}.json` + older pages at `index-{page}.json`. -**Storage costs (Cloudflare R2):** -- Replays: ~6.5 GB at steady state (90 days retention) -- Per-match metadata, thumbnails, cards: ~500 MB -- R2 free tier: 10 GB storage, 10M reads/month, 1M writes/month — - comfortably within limits at launch. Class A operations (writes) are the - binding constraint; 60 matches/hour x 2 objects = ~87K writes/month. +**Storage costs:** +- **R2 (warm cache)**: ≤10 GB free tier. At ~50 KB/replay, holds ~200K warm + replays (~139 days at 60 matches/hour). Class A writes: ~43K/month + (index builder promoting replays). Well within free tier limits. +- **B2 (cold archive)**: First 10 GB free, $0.006/GB/month after. At 60 + matches/hour, ~2.2 GB/month. Year one: ~26 GB ≈ $0.10/month. Free + egress via Cloudflare Bandwidth Alliance. +- **Pages**: No per-file storage costs. 20K file limit per deployment — only + SPA + JSON indexes, well within limits. ### 7.3 Browser Replay Viewer @@ -1140,14 +1201,14 @@ The replay viewer is a client-side TypeScript application rendered on HTML5 Canvas. **Rendering pipeline:** -1. Fetch `replay.json.gz` from R2 (`r2.aicodebattle.com`; browser handles - gzip decompression via `Accept-Encoding`) +1. Fetch `replay.json.gz` from R2 warm cache (falling back to B2 cold + archive); browser handles gzip decompression via `Accept-Encoding` 2. Parse and index: build per-turn game state by replaying events from turn 0 3. Render the current turn to canvas 4. User controls advance/rewind the turn index -No API invocations -- the viewer is a static page (served from Pages) loading -a replay file directly from R2. +No API invocations — the viewer is a static page (served from Pages) loading +a replay file from R2 (warm cache) or B2 (cold archive). **Visual design:** @@ -1583,19 +1644,23 @@ time range, human-only vs all). Auto-refresh every 60 seconds. Public ### 9.1 Design Principles -Compute runs in the **apexalgo-iad** Kubernetes cluster in a dedicated -`ai-code-battle` namespace. The web tier lives on **Cloudflare Pages** (SPA + -data indexes) and **Cloudflare R2** (replays + large data). The cluster is -existing infrastructure shared with other workloads — it already provides -PostgreSQL (CNPG), Valkey, Traefik ingress, cert-manager, ArgoCD, Argo -Workflows, Argo Events, Forgejo (git + container registry), SATA (Cinder CSI) -storage, and Sealed Secrets. +Compute runs in the **apexalgo-iad** Kubernetes cluster (Rackspace Spot) in +a dedicated `ai-code-battle` namespace. The public-facing product is a +**Cloudflare Pages** static site. Replay files use tiered storage: +**Cloudflare R2** (warm cache, free tier ≤10 GB) and **Backblaze B2** (cold +archive, permanent). The cluster is existing infrastructure shared with other +workloads — it already provides PostgreSQL (CNPG), Valkey, Traefik ingress, +cert-manager, ArgoCD, Argo Workflows, Argo Events, Forgejo (git + container +registry), SATA (Cinder CSI) storage, and Sealed Secrets. Key principles: -- **Hybrid architecture** — Cloudflare for the web tier (Pages + R2), K8s - for compute (API, workers, bots, evolver, index builder). No Nginx pod, - no PersistentVolumes for web data. +- **Static-first architecture** — the public product is a static site on + Pages. All data visitors see is pre-computed JSON. K8s is the factory + that generates data and publishes it to Pages. +- **Tiered storage** — B2 is the permanent archive for all replays. R2 is + a warm CDN cache for recent replays, capped at the free tier. The index + builder manages the R2 lifecycle. - **GitOps via ArgoCD** — all K8s manifests are committed to git and synced by ArgoCD. Never apply manifests directly with `kubectl`. - **Argo Workflows for CI** — container image builds and static site builds @@ -1603,10 +1668,8 @@ Key principles: - **Shared infrastructure** — PostgreSQL, Valkey, Traefik, and cert-manager are cluster-level services. The ai-code-battle namespace consumes them but does not manage them. -- **Cloudflare for web serving** — Pages serves the SPA globally with - automatic CDN. R2 stores replays and large data, served via public bucket - URL. The Go API is the only K8s service exposed externally (via Traefik - at `api.aicodebattle.com`). +- **No public API initially** — the Go API for social features and third- + party registration is deferred. The v1 system is fully static. ### 9.2 Kubernetes Namespace Layout @@ -1624,58 +1687,51 @@ Cross-namespace dependencies: **Cloudflare infrastructure requirements:** -- **Cloudflare Pages project**: `aicodebattle` — hosts the static SPA and - data indexes. Deployed by the index builder via `wrangler pages deploy`. -- **Cloudflare R2 bucket**: `acb-data` — stores replays, per-match metadata, - evolution live feed, thumbnails, bot cards. Public read access via custom - domain `r2.aicodebattle.com`. Write access via S3-compatible API from K8s. -- **DNS**: `aicodebattle.com` CNAME to Pages project, - `api.aicodebattle.com` proxied to Traefik ingress IP, - `r2.aicodebattle.com` CNAME to R2 bucket public hostname. +- **Cloudflare Pages project**: `ai-code-battle` (`ai-code-battle.pages.dev`) + — hosts the static SPA and data indexes. Deployed by the index builder + via `wrangler pages deploy`. +- **Cloudflare R2 bucket**: Warm cache for recent replays, thumbnails, bot + cards. Free tier (≤10 GB). Managed by the index builder. +- **DNS** (when custom domain is desired): `aicodebattle.com` CNAME to Pages. -**K8s manifests directory structure:** +**Backblaze B2 infrastructure requirements:** + +- **B2 bucket**: Cold archive for ALL replays and match data, permanently. + Match workers upload directly via S3-compatible API. Free egress via + Cloudflare Bandwidth Alliance. + +**K8s manifests directory structure** (flat — per cluster CLAUDE.md norms): ``` -cluster-configuration/apexalgo-iad/ai-code-battle/ -├── namespace.yaml -├── argocd-application.yaml -├── sealed-secrets/ -│ ├── postgres-credentials.yaml -│ ├── valkey-credentials.yaml -│ ├── api-key.yaml -│ ├── cloudflare-api-token.yaml (for wrangler pages deploy) -│ └── r2-credentials.yaml (S3-compatible access key for R2) -├── pvcs/ -│ └── evolver-sandbox.yaml (SATA (Cinder CSI) RWO PVC for nsjail workspace) -├── deployments/ -│ ├── acb-api.yaml -│ ├── acb-worker.yaml -│ ├── acb-evolver.yaml -│ ├── acb-index-builder.yaml -│ ├── acb-strategy-random.yaml -│ ├── acb-strategy-gatherer.yaml -│ ├── acb-strategy-rusher.yaml -│ ├── acb-strategy-guardian.yaml -│ ├── acb-strategy-swarm.yaml -│ └── acb-strategy-hunter.yaml -├── services/ -│ ├── acb-api.yaml -│ ├── acb-strategy-random.yaml -│ ├── acb-strategy-gatherer.yaml -│ ├── acb-strategy-rusher.yaml -│ ├── acb-strategy-guardian.yaml -│ ├── acb-strategy-swarm.yaml -│ └── acb-strategy-hunter.yaml -├── ingress/ -│ └── ingressroute-api.yaml (api.aicodebattle.com -> acb-api Service) -├── certificates/ -│ └── api-aicodebattle-com.yaml (cert-manager Certificate for api subdomain) -└── workflows/ - ├── build-site.yaml (Argo Workflow template: npm build -> artifact) - ├── build-images.yaml (Argo Workflow template: docker build -> Forgejo) - └── sensor-github-push.yaml (Argo Events sensor: git push -> workflows) +declarative-config/k8s/apexalgo-iad/ai-code-battle/ +├── namespace.yml +├── acb-database.yml (ext-postgres-operator Postgres + PostgresUser) +├── acb-schema-init.yml (ConfigMap + Deployment for schema migration) +├── acb-matchmaker-deployment.yml (matchmaker: pairings, job enqueue, health, reaper) +├── acb-worker-deployment.yml (match workers: run matches, upload to B2) +├── acb-index-builder-deployment.yml (index builder: generate JSON, deploy to Pages, manage R2) +├── acb-evolver-deployment.yml (LLM evolution pipeline) +├── acb-strategy-random-deployment.yml (RandomBot — Python) +├── acb-strategy-random-service.yml +├── acb-strategy-gatherer-deployment.yml (GathererBot — Go) +├── acb-strategy-gatherer-service.yml +├── acb-strategy-rusher-deployment.yml (RusherBot — Rust) +├── acb-strategy-rusher-service.yml +├── acb-strategy-guardian-deployment.yml (GuardianBot — PHP) +├── acb-strategy-guardian-service.yml +├── acb-strategy-swarm-deployment.yml (SwarmBot — TypeScript) +├── acb-strategy-swarm-service.yml +├── acb-strategy-hunter-deployment.yml (HunterBot — Java) +├── acb-strategy-hunter-service.yml +├── acb-secret.yml.template (template: B2/R2/Cloudflare/bot secrets) +└── acb-sealedsecret.yml (sealed version of above) ``` +Secrets already provisioned in the namespace: `acb-app-credentials-acb-app` +(PostgreSQL), `keydb-secret` (Valkey), `backblaze-secret` (B2), +`cloudflare-pages-secret` (wrangler), `docker-hub-registry` (image pulls), +`openai-secret` (evolver LLM). + ### 9.3 Container Images All container images are built by Argo Workflows and pushed to the Forgejo @@ -1683,10 +1739,10 @@ container registry (`forgejo.ardenone.com/ai-code-battle/`). | Image | Base | Purpose | K8s Resource | |-------|------|---------|--------------| -| `acb-api` | Go binary on Alpine | API + scheduling | Deployment (1 replica) | -| `acb-worker` | Go binary on Alpine | Match execution | Deployment (2-10 replicas) | +| `acb-matchmaker` | Go binary on Alpine | Matchmaking, health checks, stale job reaping | Deployment (1 replica) | +| `acb-worker` | Go binary on Alpine | Match execution, B2 upload | Deployment (2-10 replicas) | | `acb-evolver` | Go binary on Alpine | Evolution pipeline | Deployment (1 replica) | -| `acb-index-builder` | Go binary on Alpine (includes `wrangler` CLI) | Reads PostgreSQL, generates JSON indexes, deploys to Cloudflare Pages, prunes old replays on R2 weekly | Deployment (sleep-loop, 15 min cycle, Pages deploy every ~90 min, self-restarts every 4h) | +| `acb-index-builder` | Go binary on Alpine (includes `wrangler` CLI) | Reads PostgreSQL, generates JSON indexes, deploys to Pages, manages R2 warm cache (promote from B2, prune) | Deployment (sleep-loop, 15 min cycle, Pages deploy every ~90 min, self-restarts every 4h) | | `acb-strategy-random` | Python 3.13 slim | RandomBot | Deployment (1 replica) | | `acb-strategy-gatherer` | Go on Alpine | GathererBot | Deployment (1 replica) | | `acb-strategy-rusher` | Rust on Alpine | RusherBot | Deployment (1 replica) | @@ -1698,31 +1754,30 @@ container registry (`forgejo.ardenone.com/ai-code-battle/`). ### 9.4 Match Job Coordination Match workers coordinate via **Valkey** (job queue) and **PostgreSQL** -(persistent state). The Go API is the single point of coordination. +(persistent state). The matchmaker Deployment is the coordination point. **Job flow:** -1. Matchmaker ticker (in Go API) queries active bots from PostgreSQL, - computes pairings, inserts match + job rows in PostgreSQL, enqueues - job IDs into a Valkey list (`acb:jobs:pending`) +1. Matchmaker Deployment queries active bots from PostgreSQL, computes + pairings, inserts match + job rows in PostgreSQL, enqueues job IDs + into a Valkey list (`acb:jobs:pending`) 2. Match worker pod BRPOPs from the Valkey list (blocking dequeue) 3. Worker fetches full job config from PostgreSQL (map data, bot endpoints + shared secrets for HMAC signing, match config) 4. Worker executes the full match (500 turns, HTTP calls to bot Services via cluster DNS, e.g. `acb-strategy-rusher.ai-code-battle.svc:8080`) -5. Worker uploads replay file to R2 via S3-compatible API +5. Worker uploads replay file to **B2** via S3-compatible API (`replays/{match_id}.json.gz`) -6. Worker uploads match metadata to R2 +6. Worker uploads match metadata to **B2** (`matches/{match_id}.json`) -7. Worker submits result to Go API: - `POST acb-api.ai-code-battle.svc:8080/api/jobs/{id}/result` - - Small JSON body: scores, winner, turn count, condition -8. Go API writes result to PostgreSQL, updates ratings (Glicko-2) -9. Index builder Deployment (next ~15-min cycle) reads new results from - PostgreSQL, rebuilds all index JSON files, deploys to Pages +7. Worker writes result directly to **PostgreSQL** (scores, winner, turn + count, condition) and updates ratings (Glicko-2) +8. Index builder (next ~15-min cycle) reads new results from PostgreSQL, + rebuilds all index JSON files, deploys to Pages, promotes recent + replays from B2 to R2 warm cache **Stale job recovery:** -- Reaper ticker (in Go API) checks PostgreSQL every 5 minutes for jobs - `running` >15 minutes +- Reaper ticker (in matchmaker) checks PostgreSQL every 5 minutes for + jobs `running` >15 minutes - Assumed abandoned (worker pod crashed or was evicted) - Re-enqueues the job ID in Valkey for retry @@ -1742,64 +1797,64 @@ Match workers coordinate via **Valkey** (job queue) and **PostgreSQL** - Matches in progress where a bot disappeared: that bot times out on each turn, its units hold position, it effectively loses. -**If the Go API pod restarts:** +**If the matchmaker pod restarts:** - Matchmaker and health checker tickers restart with the pod. - No state lost — all state is in PostgreSQL and Valkey. - Workers continue BRPOPing from Valkey (the queue persists). - Brief gap in matchmaking (~1 min) while the pod starts. **If PostgreSQL or Valkey is temporarily unavailable:** -- The Go API returns 503 for API requests. Workers block on BRPOP. +- Workers block on BRPOP. Matchmaker retries on its next tick. - CNPG handles PostgreSQL failover automatically (3-node cluster). - When service resumes, everything recovers without intervention. ### 9.6 Networking & Security **External traffic:** -- `aicodebattle.com` -> Cloudflare Pages (static SPA + data indexes) -- `r2.aicodebattle.com` -> Cloudflare R2 (replays, per-match data, thumbnails) -- `api.aicodebattle.com` -> Cloudflare (CDN/DDoS) -> Traefik ingress -> - Go API Service -- TLS: Pages and R2 handle TLS automatically. cert-manager issues Let's - Encrypt certificates for `api.aicodebattle.com` via Traefik. +- `ai-code-battle.pages.dev` (or custom domain) → Cloudflare Pages + (static SPA + data indexes) +- R2 public URL → Cloudflare R2 (warm replay cache) +- B2 public URL (via Cloudflare CDN) → Backblaze B2 (cold replay archive) +- No K8s services are exposed externally in v1. The Go API IngressRoute + at `api.aicodebattle.com` is planned for when social features are added. +- TLS: Pages and R2 handle TLS automatically. B2 via Cloudflare gets TLS + from the CDN layer. **Cluster-internal traffic:** -- Go API -> PostgreSQL: `cnpg-apexalgo-rw.cnpg.svc.cluster.local:5432` -- Go API -> Valkey: `valkey-master.valkey.svc.cluster.local:6379` +- Matchmaker -> PostgreSQL: `cnpg-apexalgo-rw.cnpg.svc.cluster.local:5432` +- Matchmaker -> Valkey: `valkey-master.valkey.svc.cluster.local:6379` - Workers -> Valkey: same +- Workers -> PostgreSQL: same - Workers -> Bot Services: `acb-strategy-*.ai-code-battle.svc:8080` - Workers -> External bots: outbound HTTPS to registered URLs -- Index Builder -> PostgreSQL: same as Go API +- Workers -> B2: HTTPS (S3-compatible API, for replay upload) +- Index Builder -> PostgreSQL: same - Index Builder -> Cloudflare Pages: HTTPS (wrangler CLI) -- Index Builder -> R2: HTTPS (S3-compatible API, for pruning) -- Workers -> R2: HTTPS (S3-compatible API, for replay upload) -- Evolver -> R2: HTTPS (S3-compatible API, for live.json upload) +- Index Builder -> R2: HTTPS (S3-compatible API, for warm cache management) +- Index Builder -> B2: HTTPS (S3-compatible API, for promoting replays to R2) +- Evolver -> PostgreSQL: same - All cluster-internal traffic is plaintext (trusted network) **Security boundaries:** - The game engine (workers) never executes bot code — HTTP only - All bot responses are schema-validated before processing - HMAC authentication prevents request/response forgery -- Go API authentication for job result submission (shared API key from - SealedSecret) -- Registration endpoint validates bot URLs (no internal IPs, no - private ranges, no cluster DNS) - PostgreSQL credentials in SealedSecrets (encrypted in git, decrypted in-cluster) - Valkey access is cluster-internal only (no external exposure) -- Pages and R2 serve public-read data only — no secrets stored there -- R2 write credentials (SealedSecret) are scoped to the `acb-data` bucket -- Cloudflare API token (SealedSecret) is scoped to Pages deploy only +- Pages, R2, and B2 serve public-read data only — no secrets stored there +- B2 write credentials (SealedSecret) are scoped to the acb bucket +- Cloudflare API token (SealedSecret) is scoped to Pages deploy + R2 only - NetworkPolicy can restrict egress from bot pods to prevent data exfiltration (future hardening) ### 9.7 ArgoCD & GitOps All K8s manifests for the `ai-code-battle` namespace are stored in the -cluster-configuration git repo: +declarative-config repo (ardenone-cluster): ``` -cluster-configuration/apexalgo-iad/ai-code-battle/ +declarative-config/k8s/apexalgo-iad/ai-code-battle/ ``` An ArgoCD Application watches this directory and syncs changes @@ -1814,8 +1869,8 @@ metadata: spec: project: default source: - repoURL: https://forgejo.ardenone.com/infra/cluster-configuration.git - path: apexalgo-iad/ai-code-battle + repoURL: https://forgejo.ardenone.com/infra/ardenone-cluster.git + path: declarative-config/k8s/apexalgo-iad/ai-code-battle targetRevision: main destination: server: https://kubernetes.default.svc @@ -1827,7 +1882,7 @@ spec: ``` **Workflow for infrastructure changes:** -1. Edit manifests in `cluster-configuration/` repo +1. Edit manifests in `declarative-config/k8s/` (ardenone-cluster repo) 2. `git push` to origin 3. ArgoCD detects the change and syncs within ~3 minutes 4. Verify via: `kubectl --server=http://kubectl-apexalgo-iad:8001 get pods -n ai-code-battle` @@ -1862,7 +1917,7 @@ appropriate Argo Workflow(s). 3. Builds a container image (Kaniko) 4. Pushes to Forgejo registry 5. Creates a Deployment + Service manifest -6. Commits the manifest to the cluster-configuration repo +6. Commits the manifest to the declarative-config repo 7. ArgoCD syncs the new bot into the cluster ### 9.9 Monitoring @@ -1871,17 +1926,17 @@ appropriate Argo Workflow(s). |--------|--------|-------| | Pod health | Kubernetes liveness/readiness probes | Auto-restart | | Pages up | Cloudflare Pages analytics + synthetic checks | Cloudflare handles failover | -| API errors | Go API metrics (Prometheus endpoint) | Error rate >5% | | PostgreSQL health | CNPG operator monitoring | Auto-failover | | Valkey health | Kubernetes probes | Auto-restart | | Match throughput | PostgreSQL query: completions per hour | <10/hour for >1 hour | | Worker queue depth | Valkey LLEN on `acb:jobs:pending` | >50 pending for >30 min | -| Bot health failures | Go API health checker ticker | >50% failing | -| Stale jobs | Go API reaper ticker count | >10 stale in a cycle | -| R2 usage | Cloudflare R2 dashboard / API metrics | >8 GB (approaching free tier limit) | +| Bot health failures | Matchmaker health checker ticker | >50% failing | +| Stale jobs | Matchmaker reaper ticker count | >10 stale in a cycle | +| R2 usage | Index builder tracks warm cache size | >8 GB (approaching 10 GB free tier cap) | +| B2 usage | B2 dashboard / API metrics | Informational only (no cap) | -Alerts via Go API -> webhook to Discord/Slack. Cluster-level monitoring -(Prometheus, if deployed) can scrape the Go API's `/metrics` endpoint. +Alerts via matchmaker -> webhook to Discord/Slack. Cluster-level monitoring +(Prometheus, if deployed) can scrape the matchmaker's `/metrics` endpoint. --- @@ -2540,8 +2595,8 @@ ai-code-battle/ | Resource | Source | Namespace | |----------|--------|-----------| -| All Deployments, Services, Deployments, PVCs, IngressRoutes, SealedSecrets | `cluster-configuration/apexalgo-iad/ai-code-battle/` | `ai-code-battle` | -| ArgoCD Application | `cluster-configuration/apexalgo-iad/ai-code-battle/argocd-application.yaml` | `argocd` | +| All Deployments, Services, Deployments, PVCs, IngressRoutes, SealedSecrets | `declarative-config/k8s/apexalgo-iad/ai-code-battle/` | `ai-code-battle` | +| ArgoCD Application | `declarative-config/k8s/apexalgo-iad/ai-code-battle/argocd-application.yaml` | `argocd` | | PostgreSQL database `acb` | Created in existing CNPG cluster `cnpg-apexalgo` | `cnpg` | **WASM artifacts (built at compile time, deployed to Cloudflare Pages):** @@ -2593,7 +2648,7 @@ Source (git push to main) │ ├── WASM builds (engine + 6 bots) │ └── Push site build artifact to Forgejo registry │ - ├──► ArgoCD (watches cluster-configuration repo) + ├──► ArgoCD (watches declarative-config repo) │ └── Syncs K8s manifests -> ai-code-battle namespace │ └──► PostgreSQL migrations @@ -2615,8 +2670,8 @@ Several components share code. The monorepo structure avoids duplication: | `engine/` | `acb-worker`, `acb-evolver`, `acb-local`, `acb-mapgen`, WASM engine | Go | | `engine/replay.go` | `acb-worker` (write), `acb-index-builder` (read for stats) | Go | | `engine/winprob.go` | `acb-worker` (post-match computation) | Go | -| `cmd/acb-api/lib/hmac.go` | Go API | Go | -| `cmd/acb-api/lib/glicko2.go` | Go API (rating updates) | Go | +| `engine/auth.go` | Match workers, matchmaker (HMAC verification) | Go | +| `engine/glicko2.go` | Match workers (rating updates on result write) | Go | | `web/src/components/replay-canvas.ts` | Full viewer, embed, sandbox, homepage | TypeScript | | `web/src/lib/data.ts` | All pages (data fetching + caching) | TypeScript | @@ -2677,64 +2732,59 @@ match with all visual elements rendering correctly. ### Phase 4: Match Orchestration **Deliverables:** -- Go API service (`acb-api`): job coordination endpoint - (`POST /api/jobs/{id}/result`), authenticated with API key from SealedSecret +- Matchmaker Deployment (`acb-matchmaker`): internal tickers for pairing + bots (1 min), health checking (15 min), stale job reaping (5 min). + Enqueues job IDs into Valkey. No external exposure. - PostgreSQL schema (CNPG): `bots`, `matches`, `match_participants`, `jobs`, `rating_history` tables in the `acb` database -- Go API internal tickers: matchmaker (1 min), health checker (15 min), - stale job reaper (5 min) -- Index builder Deployment: reads PostgreSQL directly, generates index JSON - files every ~15 min, deploys to Cloudflare Pages every ~90 min -- Match worker Deployment (`acb-worker`): dequeues jobs from Valkey, runs - matches, uploads replays to R2, POSTs results to Go API -- Glicko-2 rating update logic in the Go API (runs on result submission) +- Index builder Deployment (`acb-index-builder`): reads PostgreSQL directly, + generates index JSON files every ~15 min, deploys to Cloudflare Pages + every ~90 min, manages R2 warm cache (promote from B2, prune old) +- Match worker Deployment (`acb-worker`): BRPOPs jobs from Valkey, runs + matches, uploads replays to B2, writes results + Glicko-2 ratings to + PostgreSQL +- Glicko-2 rating update logic in the match worker (runs on result write) -**Exit criteria:** matchmaker ticker creates jobs and enqueues them in Valkey, -worker pods dequeue and execute them, replays land on R2, results flow +**Exit criteria:** matchmaker creates jobs and enqueues them in Valkey, +worker pods dequeue and execute them, replays land on B2, results flow into PostgreSQL, ratings update, and leaderboard.json rebuilds automatically. System recovers from worker pod failure via the stale job reaper. ### Phase 5: Web Platform **Deliverables:** -- Cloudflare Pages project serving static SPA: leaderboard, match history, - bot profiles, replay viewer, registration form, docs/getting-started page -- Cloudflare R2 bucket serving replays and per-match metadata -- Go API: registration endpoints (`/api/register`, `/api/rotate-key`, - `/api/status/{id}`) -- Go API internal ticker: health checker (15 min) -- pings bot endpoints - via cluster DNS, updates PostgreSQL -- Traefik IngressRoute for `api.aicodebattle.com` (Go API) -- cert-manager Certificate for TLS on the API subdomain -- Index builder Deployment deploying leaderboard, bot profiles, playlists - to Pages; match workers uploading replays and per-match metadata to R2 +- Cloudflare Pages static SPA (`ai-code-battle.pages.dev`): leaderboard, + match history, bot profiles, replay viewer, docs/getting-started page +- SPA fetches replay files from R2 warm cache with B2 cold archive fallback +- Index builder deploying leaderboard, bot profiles, playlists to Pages +- Match workers uploading replays and per-match metadata to B2 +- Index builder promoting recent replays from B2 to R2 warm cache -**Exit criteria:** a participant can register a bot via the web form, the -bot appears on the leaderboard after matches complete, and anyone can browse -matches and watch replays -- SPA from Pages, replays from R2, API from K8s. +**Exit criteria:** anyone can browse matches, view leaderboards, and watch +replays — SPA from Pages, recent replays from R2, old replays from B2. +All data is static and pre-computed. ### Phase 6: Deployment & Production **Deliverables:** -- K8s manifests committed to `cluster-configuration/apexalgo-iad/ai-code-battle/`: - namespace, Deployments, Services, IngressRoute, SealedSecrets, - cert-manager Certificate -- Cloudflare Pages project (`aicodebattle`) and R2 bucket (`acb-data`) - provisioned with custom domains +- K8s manifests committed to `declarative-config/k8s/apexalgo-iad/ai-code-battle/`: + namespace, Deployments, Services, SealedSecrets (flat directory structure) +- Cloudflare Pages project (`ai-code-battle`, already exists at + `ai-code-battle.pages.dev`) +- Cloudflare R2 bucket (warm replay cache, free tier) +- Backblaze B2 bucket (cold replay archive) - ArgoCD Application syncing the manifests directory - Argo Events sensor: GitHub webhook triggers on push to `ai-code-battle` repo - Argo Workflows: image build (Kaniko -> Forgejo registry), site build (npm build -> artifact for Pages deploy) -- Cloudflare DNS: `aicodebattle.com` CNAME to Pages, - `api.aicodebattle.com` proxied to Traefik, `r2.aicodebattle.com` to R2 -- SealedSecrets for PostgreSQL credentials, Valkey credentials, API key, - Cloudflare API token, R2 credentials -- Monitoring: Go API metrics endpoint + Discord/Slack alerting webhooks +- SealedSecrets for PostgreSQL, Valkey, B2, R2, Cloudflare API token + (most already provisioned in the namespace) +- Monitoring: matchmaker metrics endpoint + Discord/Slack alerting webhooks -**Exit criteria:** platform is publicly accessible — SPA from Pages, replays -from R2, API from K8s via Traefik. All K8s manifests are GitOps-managed by -ArgoCD, CI pipelines rebuild images and site on git push, and external -participants can register and play. +**Exit criteria:** platform is publicly accessible — SPA from Pages, recent +replays from R2, old replays from B2. All K8s manifests are GitOps-managed +by ArgoCD, CI pipelines rebuild images and site on git push, matches run +autonomously, and the leaderboard updates every ~90 minutes. ### Phase 7: LLM-Driven Evolution diff --git a/engine/bot_http.go b/engine/bot_http.go index 528a683..6063ca9 100644 --- a/engine/bot_http.go +++ b/engine/bot_http.go @@ -19,7 +19,8 @@ type HTTPBot struct { matchID string turn int crashed bool - failCount int // consecutive failures + failCount int // consecutive failures + lastDebug *DebugInfo // debug info from last response } // HTTPOption is a functional option for HTTPBot. @@ -170,12 +171,20 @@ func (b *HTTPBot) GetMoves(state *VisibleState) ([]Move, error) { // Validate moves (basic validation) moves := b.validateMoves(moveResp.Moves, state) + // Store debug info for replay + b.lastDebug = moveResp.Debug + // Reset failure count on success b.failCount = 0 return moves, nil } +// LastDebug returns the debug info from the most recent response, or nil. +func (b *HTTPBot) LastDebug() *DebugInfo { + return b.lastDebug +} + // validateMoves validates and filters moves against the current state. func (b *HTTPBot) validateMoves(moves []Move, state *VisibleState) []Move { // Build set of owned bot positions diff --git a/engine/match.go b/engine/match.go index 86ddc7c..70dea3f 100644 --- a/engine/match.go +++ b/engine/match.go @@ -76,6 +76,11 @@ func (mr *MatchRunner) AddBot(bot BotInterface, name string) { mr.names = append(mr.names, name) } +// DebugProvider is an optional interface bots may implement to expose debug telemetry. +type DebugProvider interface { + LastDebug() *DebugInfo +} + // Run executes the match and returns the result and replay. func (mr *MatchRunner) Run() (*MatchResult, *Replay, error) { if len(mr.bots) < 2 { @@ -106,8 +111,8 @@ func (mr *MatchRunner) Run() (*MatchResult, *Replay, error) { // Record initial map state replayWriter.SetMap(gs) - // Record turn 0 (initial state) - replayWriter.RecordTurn(gs) + // Record turn 0 (initial state, no debug yet) + replayWriter.RecordTurn(gs, nil) // Run the match var result *MatchResult @@ -130,8 +135,21 @@ func (mr *MatchRunner) Run() (*MatchResult, *Replay, error) { // Execute the turn result = gs.ExecuteTurn() - // Record turn state - replayWriter.RecordTurn(gs) + // Collect debug telemetry from bots that support it + var debug map[int]*DebugInfo + for i, bot := range mr.bots { + if dp, ok := bot.(DebugProvider); ok { + if d := dp.LastDebug(); d != nil { + if debug == nil { + debug = make(map[int]*DebugInfo) + } + debug[i] = d + } + } + } + + // Record turn state with debug + replayWriter.RecordTurn(gs, debug) if mr.verbose { mr.logger.Printf("Turn %d: %d living bots", gs.Turn, gs.GetLivingBotCount()) diff --git a/engine/replay.go b/engine/replay.go index cf4075f..93fdd63 100644 --- a/engine/replay.go +++ b/engine/replay.go @@ -9,14 +9,15 @@ import ( // Replay records the complete history of a match for playback. type Replay struct { - MatchID string `json:"match_id"` - Config Config `json:"config"` - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time"` - Result *MatchResult `json:"result"` - Players []ReplayPlayer `json:"players"` - Map ReplayMap `json:"map"` - Turns []ReplayTurn `json:"turns"` + FormatVersion string `json:"format_version"` // semver, e.g. "1.0" + MatchID string `json:"match_id"` + Config Config `json:"config"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Result *MatchResult `json:"result"` + Players []ReplayPlayer `json:"players"` + Map ReplayMap `json:"map"` + Turns []ReplayTurn `json:"turns"` } // ReplayPlayer represents player info in a replay. @@ -42,13 +43,14 @@ type ReplayCore struct { // ReplayTurn represents the state at a single turn. type ReplayTurn struct { - Turn int `json:"turn"` - Bots []ReplayBot `json:"bots"` - Cores []ReplayCoreState `json:"cores"` - Energy []Position `json:"energy"` - Scores []int `json:"scores"` - EnergyHeld []int `json:"energy_held"` - Events []Event `json:"events,omitempty"` + Turn int `json:"turn"` + Bots []ReplayBot `json:"bots"` + Cores []ReplayCoreState `json:"cores"` + Energy []Position `json:"energy"` + Scores []int `json:"scores"` + EnergyHeld []int `json:"energy_held"` + Events []Event `json:"events,omitempty"` + Debug map[int]*DebugInfo `json:"debug,omitempty"` // optional bot debug telemetry } // ReplayBot represents a bot in a replay turn. @@ -77,9 +79,10 @@ type ReplayWriter struct { func NewReplayWriter(matchID string, config Config) *ReplayWriter { return &ReplayWriter{ replay: &Replay{ - MatchID: matchID, - Config: config, - StartTime: time.Now().UTC(), + FormatVersion: "1.0", + MatchID: matchID, + Config: config, + StartTime: time.Now().UTC(), }, turns: make([]ReplayTurn, 0), startTime: time.Now(), @@ -123,7 +126,8 @@ func (rw *ReplayWriter) SetMap(gs *GameState) { } // RecordTurn records the state at the end of a turn. -func (rw *ReplayWriter) RecordTurn(gs *GameState) { +// debug is an optional map of player ID -> DebugInfo collected from bot responses. +func (rw *ReplayWriter) RecordTurn(gs *GameState, debug map[int]*DebugInfo) { turn := ReplayTurn{ Turn: gs.Turn, Bots: make([]ReplayBot, 0), @@ -132,6 +136,7 @@ func (rw *ReplayWriter) RecordTurn(gs *GameState) { Scores: make([]int, len(gs.Players)), EnergyHeld: make([]int, len(gs.Players)), Events: gs.Events, + Debug: debug, } // Record all bots (including dead ones for death animation) diff --git a/web/app.html b/web/app.html index 603f678..6cb0ce1 100644 --- a/web/app.html +++ b/web/app.html @@ -678,8 +678,13 @@ Leaderboard Matches Bots + Evolution + Rivalries + Sandbox + Clip Maker + Feedback Register - Replay Viewer + Replay diff --git a/web/src/api-types.ts b/web/src/api-types.ts index c45fa20..a608a7a 100644 --- a/web/src/api-types.ts +++ b/web/src/api-types.ts @@ -93,6 +93,79 @@ export interface RegisterResponse { error?: string; } +// Evolution dashboard types +export interface IslandStat { + count: number; + best_fitness: number; + avg_fitness: number; + diversity: number; + promoted_count: number; +} + +export interface GenerationEntry { + generation: number; + island: string; + evaluated_at: string; + count: number; + promoted: number; + best_fitness: number; + avg_fitness: number; +} + +export interface LineageNode { + id: number; + parent_ids: number[]; + generation: number; + island: string; + fitness: number; + promoted: boolean; + language: string; + created_at: string; +} + +export interface MetaSnapshot { + generation: number; + island_counts: Record; + island_best_fitness: Record; +} + +export interface EvolutionLiveData { + updated_at: string; + total_programs: number; + promoted_count: number; + islands: Record; + generation_log: GenerationEntry[]; + lineage: LineageNode[]; + meta_snapshots: MetaSnapshot[]; +} + +// Blog / Narrative Engine types + +export interface BlogWeekStats { + matches_played: number; + top_bot: string; + top_bot_rating: number; + biggest_upset: string | null; + most_active_bot: string; + most_active_bot_matches: number; + island_leader: string | null; +} + +export interface BlogPost { + slug: string; + title: string; + published_at: string; + week_start: string; + summary: string; + body_html: string; + stats: BlogWeekStats; +} + +export interface BlogIndex { + updated_at: string; + posts: BlogPost[]; +} + // API configuration export const API_BASE = '/api'; @@ -130,6 +203,24 @@ export async function registerBot(request: RegisterRequest): Promise { + const response = await fetch('/data/evolution/live.json'); + if (!response.ok) throw new Error(`Failed to fetch evolution data: ${response.status}`); + return response.json(); +} + +export async function fetchBlogIndex(): Promise { + const response = await fetch('/data/blog/index.json'); + if (!response.ok) throw new Error(`Failed to fetch blog index: ${response.status}`); + return response.json(); +} + +export async function fetchBlogPost(slug: string): Promise { + const response = await fetch(`/data/blog/${slug}.json`); + if (!response.ok) throw new Error(`Failed to fetch blog post: ${response.status}`); + return response.json(); +} + export async function rotateApiKey(botId: string, currentKey: string): Promise { const response = await fetch(`${API_BASE}/rotate-key`, { method: 'POST', diff --git a/web/src/app.ts b/web/src/app.ts index 5f10e72..f8c2dd7 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -6,6 +6,11 @@ import { renderMatchesPage } from './pages/matches'; import { renderBotsPage } from './pages/bots'; import { renderBotProfilePage } from './pages/bot-profile'; import { renderRegisterPage } from './pages/register'; +import { renderEvolutionPage } from './pages/evolution'; +import { renderSandboxPage } from './pages/sandbox'; +import { renderClipMakerPage } from './pages/clip-maker'; +import { renderRivalriesPage } from './pages/rivalries'; +import { renderFeedbackPage } from './pages/feedback'; import { ReplayViewer } from './replay-viewer'; import type { Replay } from './types'; @@ -17,6 +22,11 @@ router .on('/bots', renderBotsPage) .on('/bot/:id', renderBotProfilePage) .on('/register', renderRegisterPage) + .on('/evolution', renderEvolutionPage) + .on('/sandbox', renderSandboxPage) + .on('/clip-maker', renderClipMakerPage) + .on('/rivalries', renderRivalriesPage) + .on('/feedback', renderFeedbackPage) .on('/replay', renderReplayPage) .on('/docs', renderDocsPage) .notFound(renderNotFoundPage); diff --git a/web/src/commentary.ts b/web/src/commentary.ts new file mode 100644 index 0000000..ad1f8fb --- /dev/null +++ b/web/src/commentary.ts @@ -0,0 +1,283 @@ +// Replay enrichment: template-based AI commentary for featured matches. +// +// Commentary is generated from replay event data using a curated set of +// narrative templates. For production, these can be enhanced with an LLM +// by POST-ing the context to /api/commentary. + +import type { WinProbPoint, CriticalMoment } from './win-probability'; + +export interface CommentaryLine { + turn: number; + text: string; + importance: 'low' | 'medium' | 'high'; + type: 'action' | 'analysis' | 'color' | 'milestonecomment'; +} + +export interface MatchCommentary { + matchId: string; + intro: string; + lines: CommentaryLine[]; + outro: string; + generatedAt: string; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Commentary generator +// ──────────────────────────────────────────────────────────────────────────── + +export function generateCommentary( + replay: any, + winProb: WinProbPoint[], + criticalMoments: CriticalMoment[], + playerNames?: string[], +): MatchCommentary { + const p0 = playerNames?.[0] ?? replay.players?.[0]?.name ?? 'Player 0'; + const p1 = playerNames?.[1] ?? replay.players?.[1]?.name ?? 'Player 1'; + const totalTurns = replay.result?.turns ?? replay.turns?.length ?? 0; + const winner = replay.result?.winner ?? -1; + const reason = replay.result?.reason ?? 'unknown'; + + const lines: CommentaryLine[] = []; + + // Intro + const intro = pickTemplate(INTROS, { p0, p1, turns: totalTurns, reason }); + + // Scan turns for notable events + const turns = replay.turns ?? []; + let prevP0Prob = 0.5; + + for (const turn of turns) { + const t = turn.turn; + const events: any[] = turn.events ?? []; + + for (const ev of events) { + switch (ev.type) { + case 'bot_died': + if (events.filter((e: any) => e.type === 'bot_died').length >= 3 && lines.every(l => l.turn !== t)) { + lines.push({ + turn: t, + text: pickTemplate(MASS_KILL_TEMPLATES, { p0, p1, count: events.filter((e: any) => e.type === 'bot_died').length }), + importance: 'medium', + type: 'action', + }); + } + break; + case 'core_captured': + lines.push({ + turn: t, + text: pickTemplate(CORE_CAPTURE_TEMPLATES, { + p0, p1, + capturer: ev.details?.captureOwner === 0 ? p0 : p1, + victim: ev.details?.coreOwner === 0 ? p0 : p1, + }), + importance: 'high', + type: 'action', + }); + break; + case 'bot_spawned': + if (t % 20 === 0) { // Only comment on spawns occasionally + lines.push({ + turn: t, + text: pickTemplate(SPAWN_TEMPLATES, { + player: ev.details?.owner === 0 ? p0 : p1, + }), + importance: 'low', + type: 'color', + }); + } + break; + } + } + + // Probability-based commentary + const probPoint = winProb.find(wp => wp.turn === t); + if (probPoint) { + const delta = probPoint.p0WinProb - prevP0Prob; + if (Math.abs(delta) >= 0.2) { + lines.push({ + turn: t, + text: pickTemplate(PROB_SWING_TEMPLATES, { + p0, p1, + leading: delta > 0 ? p0 : p1, + trailing: delta > 0 ? p1 : p0, + prob: Math.round(Math.max(probPoint.p0WinProb, probPoint.p1WinProb) * 100), + }), + importance: 'medium', + type: 'analysis', + }); + } + prevP0Prob = probPoint.p0WinProb; + } + + // Milestone turns + if (t === Math.floor(totalTurns * 0.25)) { + const p0Bots = turn.bots?.filter((b: any) => b.alive && b.owner === 0).length ?? 0; + const p1Bots = turn.bots?.filter((b: any) => b.alive && b.owner === 1).length ?? 0; + lines.push({ + turn: t, + text: pickTemplate(QUARTER_TEMPLATES, { p0, p1, p0Bots, p1Bots }), + importance: 'medium', + type: 'milestonecomment', + }); + } + if (t === Math.floor(totalTurns * 0.5)) { + const p0Score = turn.scores?.[0] ?? 0; + const p1Score = turn.scores?.[1] ?? 0; + lines.push({ + turn: t, + text: pickTemplate(HALFWAY_TEMPLATES, { p0, p1, p0Score, p1Score }), + importance: 'medium', + type: 'milestonecomment', + }); + } + } + + // Add critical moments that aren't already covered + for (const cm of criticalMoments) { + if (!lines.find(l => l.turn === cm.turn)) { + lines.push({ + turn: cm.turn, + text: cm.description, + importance: 'high', + type: 'analysis', + }); + } + } + + // Sort by turn + lines.sort((a, b) => a.turn - b.turn); + + // Outro + const outro = buildOutro({ winner, p0, p1, reason, totalTurns }); + + return { + matchId: replay.match_id, + intro, + lines, + outro, + generatedAt: new Date().toISOString(), + }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Template rendering +// ──────────────────────────────────────────────────────────────────────────── + +function pickTemplate(templates: string[], vars: Record): string { + const tmpl = templates[Math.floor(Math.random() * templates.length)]; + return tmpl.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? `{${k}}`)); +} + +function buildOutro(vars: { winner: number; p0: string; p1: string; reason: string; totalTurns: number }): string { + if (vars.winner < 0) return pickTemplate(DRAW_OUTROS, vars); + const winnerName = vars.winner === 0 ? vars.p0 : vars.p1; + const loserName = vars.winner === 0 ? vars.p1 : vars.p0; + return pickTemplate(WIN_OUTROS, { ...vars, winner: winnerName, loser: loserName }); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Commentary renderer (HTML) +// ──────────────────────────────────────────────────────────────────────────── + +export function renderCommentaryPanel(container: HTMLElement, commentary: MatchCommentary, currentTurn?: number): void { + const lines = currentTurn !== undefined + ? commentary.lines.filter(l => l.turn <= currentTurn) + : commentary.lines; + + container.innerHTML = ` +

+

${escapeHtml(commentary.intro)}

+
+ ${lines.slice(-10).reverse().map(l => ` +
+ Turn ${l.turn} + ${escapeHtml(l.text)} +
+ `).join('')} +
+ ${currentTurn !== undefined && currentTurn >= (commentary.lines[commentary.lines.length - 1]?.turn ?? 0) - 5 + ? `

${escapeHtml(commentary.outro)}

` : ''} +
+ `; +} + +export const COMMENTARY_STYLES = ` + +`; + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Template banks +// ──────────────────────────────────────────────────────────────────────────── + +const INTROS = [ + "Welcome to this clash between {p0} and {p1} on a {turns}-turn battlefield. May the best algorithm win!", + "It's {p0} vs {p1} in what promises to be a tactical showdown. {turns} turns stand between them and glory.", + "Two bots enter, one leaves victorious. {p0} and {p1} face off in a contest of strategy and speed.", + "The grid is set, the bots are ready. {p0} against {p1} — {turns} turns to prove dominance.", + "In the arena of silicon and logic, {p0} squares up against {p1}. Let the match begin!", +]; + +const MASS_KILL_TEMPLATES = [ + "Carnage on the grid! {count} bots fall in rapid succession — neither side escapes unscathed.", + "A fierce skirmish erupts, leaving {count} units destroyed in a matter of moments.", + "The battlefield runs hot as {count} bots are eliminated in a single dramatic turn.", + "Chaos reigns! {count} bots are lost in a collision of forces.", +]; + +const CORE_CAPTURE_TEMPLATES = [ + "{capturer} strikes deep into enemy territory, razing {victim}'s core! The tactical situation shifts dramatically.", + "A bold offensive play by {capturer} — {victim}'s core falls! This could be the turning point.", + "{victim}'s core is captured by {capturer}'s forces. The tide of war is turning.", + "Critical blow! {capturer} eliminates {victim}'s core, threatening to end this match early.", +]; + +const SPAWN_TEMPLATES = [ + "{player} is rapidly expanding its forces. Numbers could be decisive here.", + "Steady energy collection allows {player} to keep the bot production line running.", + "{player}'s economy is humming — fresh units pour onto the battlefield.", +]; + +const PROB_SWING_TEMPLATES = [ + "The models give {leading} a {prob}% win probability now — {trailing} needs to respond quickly.", + "Statistical edge shifting toward {leading} ({prob}%). {trailing} is under pressure.", + "{leading} has established clear momentum, pushing win probability to {prob}%.", + "A {prob}% win probability for {leading} — but this grid has seen bigger comebacks.", +]; + +const QUARTER_TEMPLATES = [ + "Quarter-point check: {p0} has {p0Bots} bots, {p1} has {p1Bots}. {p0Bots > p1Bots ? p0 + ' holds the numerical edge' : p1 + ' has the numbers advantage'}.", + "25 turns in: bot counts are {p0}:{p0Bots} vs {p1}:{p1Bots}. The positioning battle is just beginning.", +]; + +const HALFWAY_TEMPLATES = [ + "Halfway through! Score: {p0} at {p0Score} vs {p1} at {p1Score}. {p0Score > p1Score ? p0 : p1} leads on energy collected.", + "The midpoint of the match sees {p0} scoring {p0Score} to {p1}'s {p1Score}. Still everything to play for.", +]; + +const WIN_OUTROS = [ + "{winner} clinches it via {reason}! A commanding performance that leaves no doubt about the result.", + "Victory for {winner} by {reason} — {loser} fought hard but couldn't overcome the tactical deficit.", + "{winner} takes the match! {reason} sealed the deal in {totalTurns} turns of intense grid warfare.", + "What a match! {winner} prevails through {reason}. {loser} will need to reconsider its strategy.", +]; + +const DRAW_OUTROS = [ + "The match ends in a draw after {totalTurns} turns! An evenly matched contest that honours both competitors.", + "Neither {p0} nor {p1} could claim dominance in {totalTurns} turns — honours even!", + "A stalemate after {totalTurns} turns. Both bots showed equal resilience on the grid.", +]; diff --git a/web/src/engine.ts b/web/src/engine.ts new file mode 100644 index 0000000..f0d8cf8 --- /dev/null +++ b/web/src/engine.ts @@ -0,0 +1,687 @@ +// TypeScript game engine – mirrors the Go engine for in-browser use. +// Used by the sandbox page to run matches without a server. + +export interface Position { row: number; col: number; } +export type Direction = 'N' | 'E' | 'S' | 'W' | ''; +export interface Move { position: Position; direction: Direction; } + +export interface Config { + rows: number; + cols: number; + max_turns: number; + vision_radius2: number; + attack_radius2: number; + spawn_cost: number; + energy_interval: number; +} + +export function defaultConfig(): Config { + return { + rows: 30, cols: 30, max_turns: 200, + vision_radius2: 49, attack_radius2: 5, + spawn_cost: 3, energy_interval: 10, + }; +} + +export interface Bot { id: number; owner: number; position: Position; alive: boolean; } +export interface Core { position: Position; owner: number; active: boolean; } +export interface EnergyNode { position: Position; hasEnergy: boolean; tick: number; } +export interface Player { id: number; energy: number; score: number; botCount: number; } + +export interface VisibleBot { position: Position; owner: number; } +export interface VisibleCore { position: Position; owner: number; active: boolean; } +export interface VisibleState { + match_id: string; + turn: number; + config: Config; + you: { id: number; energy: number; score: number; }; + bots: VisibleBot[]; + energy: Position[]; + cores: VisibleCore[]; + walls: Position[]; + dead: VisibleBot[]; +} + +export interface GameEvent { + type: string; + turn: number; + details?: unknown; +} + +export interface MatchResult { + winner: number; + reason: string; + turns: number; + scores: number[]; + energy: number[]; + bots_alive: number[]; +} + +export interface GameState { + config: Config; + bots: Bot[]; + cores: Core[]; + energy: EnergyNode[]; + players: Player[]; + turn: number; + matchId: string; + walls: Set; // "row,col" + events: GameEvent[]; + dominance: Map; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Utility helpers +// ──────────────────────────────────────────────────────────────────────────── + +export function posKey(p: Position): string { return `${p.row},${p.col}`; } + +export function wrap(row: number, col: number, cfg: Config): Position { + return { row: ((row % cfg.rows) + cfg.rows) % cfg.rows, col: ((col % cfg.cols) + cfg.cols) % cfg.cols }; +} + +export function applyDir(p: Position, dir: Direction, cfg: Config): Position { + switch (dir) { + case 'N': return wrap(p.row - 1, p.col, cfg); + case 'S': return wrap(p.row + 1, p.col, cfg); + case 'E': return wrap(p.row, p.col + 1, cfg); + case 'W': return wrap(p.row, p.col - 1, cfg); + default: return p; + } +} + +export function dist2(a: Position, b: Position, cfg: Config): number { + let dr = Math.abs(a.row - b.row); + let dc = Math.abs(a.col - b.col); + if (dr > cfg.rows / 2) dr = cfg.rows - dr; + if (dc > cfg.cols / 2) dc = cfg.cols - dc; + return dr * dr + dc * dc; +} + +function randInt(max: number): number { return Math.floor(Math.random() * max); } +const DIRS: Direction[] = ['N', 'E', 'S', 'W']; + +// ──────────────────────────────────────────────────────────────────────────── +// Map generation (simplified cellular-automata) +// ──────────────────────────────────────────────────────────────────────────── + +export function generateMap(cfg: Config, seed?: number): { walls: Set; cores: Core[]; energyNodes: EnergyNode[] } { + // Simple deterministic map using linear congruential generator + let s = seed ?? 42; + const lcg = () => { s = (s * 1664525 + 1013904223) & 0xffffffff; return (s >>> 0) / 0x100000000; }; + + const walls = new Set(); + const numPlayers = 2; + const rows = cfg.rows; + const cols = cfg.cols; + + // Generate wall clusters avoiding cores & centres + const wallProb = 0.15; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + if (lcg() < wallProb) { + // Rotation symmetry: place wall + 180° mirror + walls.add(posKey({ row: r, col: c })); + walls.add(posKey(wrap(rows - r - 1, cols - c - 1, cfg))); + } + } + } + + // Player cores placed symmetrically + const cores: Core[] = []; + const corePositions: Position[] = [ + { row: Math.floor(rows * 0.25), col: Math.floor(cols * 0.25) }, + { row: Math.floor(rows * 0.75), col: Math.floor(cols * 0.75) }, + ]; + for (let i = 0; i < numPlayers; i++) { + const p = corePositions[i] ?? wrap(i * Math.floor(rows / numPlayers), Math.floor(cols / 2), cfg); + walls.delete(posKey(p)); // ensure core tile is clear + cores.push({ position: p, owner: i, active: true }); + } + + // Energy nodes – 8% of tiles, avoiding walls and cores + const energyNodes: EnergyNode[] = []; + const coreSet = new Set(cores.map(c => posKey(c.position))); + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const k = posKey({ row: r, col: c }); + if (!walls.has(k) && !coreSet.has(k) && lcg() < 0.08) { + energyNodes.push({ position: { row: r, col: c }, hasEnergy: true, tick: 0 }); + } + } + } + + return { walls, cores, energyNodes }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Game state initialization +// ──────────────────────────────────────────────────────────────────────────── + +export function newGame(cfg: Config, seed?: number): GameState { + const { walls, cores, energyNodes } = generateMap(cfg, seed); + + const players: Player[] = [ + { id: 0, energy: 0, score: 0, botCount: 1 }, + { id: 1, energy: 0, score: 0, botCount: 1 }, + ]; + + // Initial bots at each core + const bots: Bot[] = cores.map((c, i) => ({ + id: i, owner: c.owner, position: { ...c.position }, alive: true, + })); + + return { + config: cfg, + bots, + cores, + energy: energyNodes, + players, + turn: 0, + matchId: `m_${Math.random().toString(36).slice(2, 10)}`, + walls, + events: [], + dominance: new Map(), + }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Visibility / fog of war +// ──────────────────────────────────────────────────────────────────────────── + +export function getVisibleState(gs: GameState, playerID: number): VisibleState { + const player = gs.players[playerID]; + if (!player) throw new Error(`no player ${playerID}`); + + const myBots = gs.bots.filter(b => b.alive && b.owner === playerID); + + // Compute visible positions (union of vision from all own bots) + const visible = new Set(); + for (const bot of myBots) { + for (let dr = -10; dr <= 10; dr++) { + for (let dc = -10; dc <= 10; dc++) { + if (dr * dr + dc * dc <= gs.config.vision_radius2) { + visible.add(posKey(wrap(bot.position.row + dr, bot.position.col + dc, gs.config))); + } + } + } + } + + const visibleBots: VisibleBot[] = []; + for (const b of gs.bots) { + if (b.alive && visible.has(posKey(b.position))) { + visibleBots.push({ position: b.position, owner: b.owner }); + } + } + + const visibleEnergy: Position[] = []; + for (const en of gs.energy) { + if (en.hasEnergy && visible.has(posKey(en.position))) { + visibleEnergy.push(en.position); + } + } + + const visibleCores: VisibleCore[] = gs.cores + .filter(c => visible.has(posKey(c.position))) + .map(c => ({ position: c.position, owner: c.owner, active: c.active })); + + const visibleWalls: Position[] = []; + for (const k of visible) { + if (gs.walls.has(k)) { + const [r, c] = k.split(',').map(Number); + visibleWalls.push({ row: r, col: c }); + } + } + + return { + match_id: gs.matchId, + turn: gs.turn, + config: gs.config, + you: { id: playerID, energy: player.energy, score: player.score }, + bots: visibleBots, + energy: visibleEnergy, + cores: visibleCores, + walls: visibleWalls, + dead: [], + }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Turn execution +// ──────────────────────────────────────────────────────────────────────────── + +export function executeTurn(gs: GameState, allMoves: Map): MatchResult | null { + gs.turn++; + gs.events = []; + + // Flatten moves: position key -> direction + const moveMap = new Map(); + for (const [, moves] of allMoves) { + for (const m of moves) { + moveMap.set(posKey(m.position), m.direction); + } + } + + // Phase 1: Movement + const intended = new Map(); // bot id -> dest + const destBots = new Map(); + + for (const b of gs.bots) { + if (!b.alive) continue; + const dir = moveMap.get(posKey(b.position)) ?? ''; + let dest = dir ? applyDir(b.position, dir as Direction, gs.config) : b.position; + if (gs.walls.has(posKey(dest))) dest = b.position; // wall blocks + intended.set(b.id, dest); + const dk = posKey(dest); + if (!destBots.has(dk)) destBots.set(dk, []); + destBots.get(dk)!.push(b); + } + + for (const b of gs.bots) { + if (!b.alive) continue; + const dest = intended.get(b.id)!; + const dk = posKey(dest); + const botsAtDest = destBots.get(dk)!; + if (botsAtDest.length > 1) { + // Check if same owner + const sameOwner = botsAtDest.every(ob => ob.owner === b.owner); + if (sameOwner) { + for (const ob of botsAtDest) killBot(gs, ob, 'collision_death'); + continue; + } + } + b.position = dest; + } + + // Phase 2: Combat (bots within attack radius kill each other pairwise) + const aliveBots = gs.bots.filter(b => b.alive); + const killed = new Set(); + for (let i = 0; i < aliveBots.length; i++) { + for (let j = i + 1; j < aliveBots.length; j++) { + const a = aliveBots[i], bBot = aliveBots[j]; + if (a.owner === bBot.owner) continue; + if (dist2(a.position, bBot.position, gs.config) <= gs.config.attack_radius2) { + killed.add(a.id); + killed.add(bBot.id); + } + } + } + for (const id of killed) { + const b = gs.bots.find(b => b.id === id); + if (b) killBot(gs, b, 'combat_death'); + } + + // Phase 3: Energy collection + const energyMap = new Map(); + for (const en of gs.energy) { + if (en.hasEnergy) energyMap.set(posKey(en.position), en); + } + const botsOnEnergy = new Map(); + for (const b of gs.bots) { + if (!b.alive) continue; + const ek = posKey(b.position); + if (energyMap.has(ek)) { + if (!botsOnEnergy.has(ek)) botsOnEnergy.set(ek, []); + botsOnEnergy.get(ek)!.push(b); + } + } + for (const [ek, bots] of botsOnEnergy) { + // Contested energy: only one owner can collect + const owners = new Set(bots.map(b => b.owner)); + if (owners.size === 1) { + const owner = bots[0].owner; + gs.players[owner].energy++; + gs.players[owner].score++; + energyMap.get(ek)!.hasEnergy = false; + gs.events.push({ type: 'energy_collected', turn: gs.turn, details: { owner } }); + } + } + + // Phase 4: Spawning (if enough energy) + for (const p of gs.players) { + if (p.energy >= gs.config.spawn_cost) { + const myCore = gs.cores.find(c => c.owner === p.id && c.active); + if (myCore) { + p.energy -= gs.config.spawn_cost; + const newBot: Bot = { + id: gs.bots.length, + owner: p.id, + position: { ...myCore.position }, + alive: true, + }; + gs.bots.push(newBot); + p.botCount++; + gs.events.push({ type: 'bot_spawned', turn: gs.turn, details: { owner: p.id } }); + } + } + } + + // Phase 5: Energy tick + for (const en of gs.energy) { + if (!en.hasEnergy) { + en.tick++; + if (en.tick >= gs.config.energy_interval) { + en.hasEnergy = true; + en.tick = 0; + } + } + } + + // Phase 6: Core capture – enemy bots on undefended cores raze them + for (const core of gs.cores) { + if (!core.active) continue; + const ck = posKey(core.position); + const onCore = gs.bots.filter(b => b.alive && posKey(b.position) === ck); + if (onCore.length > 0) { + const owners = new Set(onCore.map(b => b.owner)); + if (!owners.has(core.owner) && owners.size === 1) { + core.active = false; + gs.events.push({ type: 'core_captured', turn: gs.turn, details: { coreOwner: core.owner, captureOwner: [...owners][0] } }); + } + } + } + + // Phase 7: Dominance check + for (const p of gs.players) { + const alive = gs.bots.filter(b => b.alive); + const myCount = alive.filter(b => b.owner === p.id).length; + const total = alive.length; + if (total > 0 && myCount / total >= 0.8) { + gs.dominance.set(p.id, (gs.dominance.get(p.id) ?? 0) + 1); + if (gs.dominance.get(p.id)! >= 100) { + return buildResult(gs, p.id, 'dominance'); + } + } else { + gs.dominance.set(p.id, 0); + } + } + + // Check for elimination + for (const p of gs.players) { + const alive = gs.bots.filter(b => b.alive && b.owner === p.id); + const hasCore = gs.cores.some(c => c.owner === p.id && c.active); + if (alive.length === 0 && !hasCore) { + // This player is eliminated; find the remaining player + const survivors = gs.players.filter(op => { + const opAlive = gs.bots.filter(b => b.alive && b.owner === op.id); + const opCore = gs.cores.some(c => c.owner === op.id && c.active); + return opAlive.length > 0 || opCore; + }); + if (survivors.length === 1) { + return buildResult(gs, survivors[0].id, 'elimination'); + } + } + } + + // Turn limit + if (gs.turn >= gs.config.max_turns) { + // Winner by score + const maxScore = Math.max(...gs.players.map(p => p.score)); + const winners = gs.players.filter(p => p.score === maxScore); + const winner = winners.length === 1 ? winners[0].id : -1; + return buildResult(gs, winner, winner >= 0 ? 'turns' : 'draw'); + } + + return null; +} + +function killBot(gs: GameState, b: Bot, reason: string): void { + b.alive = false; + gs.players[b.owner].botCount = Math.max(0, gs.players[b.owner].botCount - 1); + gs.events.push({ type: 'bot_died', turn: gs.turn, details: { owner: b.owner, reason } }); +} + +function buildResult(gs: GameState, winner: number, reason: string): MatchResult { + return { + winner, + reason, + turns: gs.turn, + scores: gs.players.map(p => p.score), + energy: gs.players.map(p => p.energy), + bots_alive: gs.players.map(p => gs.bots.filter(b => b.alive && b.owner === p.id).length), + }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Built-in bot strategy implementations (TypeScript) +// ──────────────────────────────────────────────────────────────────────────── + +export type BotStrategy = (state: VisibleState) => Move[]; + +export function randomStrategy(state: VisibleState): Move[] { + const myID = state.you.id; + return state.bots + .filter(b => b.owner === myID) + .map(b => ({ position: b.position, direction: DIRS[randInt(4)] })); +} + +export function gathererStrategy(state: VisibleState): Move[] { + const myID = state.you.id; + const energySet = new Set(state.energy.map(posKey)); + const enemySet = new Set(state.bots.filter(b => b.owner !== myID).map(b => posKey(b.position))); + const cfg = state.config; + + return state.bots + .filter(b => b.owner === myID) + .map(b => { + let dir = fleeFrom(b.position, enemySet, cfg); + if (!dir) dir = toward(b.position, energySet, cfg); + return { position: b.position, direction: dir ?? DIRS[randInt(4)] }; + }); +} + +export function rusherStrategy(state: VisibleState): Move[] { + const myID = state.you.id; + const cfg = state.config; + const coreSet = new Set(state.cores.filter(c => c.owner !== myID && c.active).map(c => posKey(c.position))); + const enemySet = new Set(state.bots.filter(b => b.owner !== myID).map(b => posKey(b.position))); + + return state.bots + .filter(b => b.owner === myID) + .map(b => { + const targets = coreSet.size > 0 ? coreSet : enemySet; + const dir = toward(b.position, targets, cfg) ?? DIRS[randInt(4)]; + return { position: b.position, direction: dir }; + }); +} + +export function guardianStrategy(state: VisibleState): Move[] { + const myID = state.you.id; + const cfg = state.config; + const myCoreSet = new Set(state.cores.filter(c => c.owner === myID && c.active).map(c => posKey(c.position))); + const enemySet = new Set(state.bots.filter(b => b.owner !== myID).map(b => posKey(b.position))); + + return state.bots + .filter(b => b.owner === myID) + .map(b => { + let dir: Direction | null = null; + if (isNearSet(b.position, enemySet, cfg, cfg.attack_radius2 + 4)) { + dir = toward(b.position, enemySet, cfg); + } else { + dir = toward(b.position, myCoreSet, cfg); + } + return { position: b.position, direction: dir ?? DIRS[randInt(4)] }; + }); +} + +export function swarmStrategy(state: VisibleState): Move[] { + const myID = state.you.id; + const cfg = state.config; + const myBots = state.bots.filter(b => b.owner === myID); + + return myBots.map(b => { + let best: Direction = 'N'; + let bestScore = -Infinity; + for (const d of DIRS) { + const np = applyDir(b.position, d, cfg); + const score = myBots.reduce((s, ob) => s + dist2(np, ob.position, cfg), 0); + if (score > bestScore) { bestScore = score; best = d; } + } + return { position: b.position, direction: best }; + }); +} + +export function hunterStrategy(state: VisibleState): Move[] { + const myID = state.you.id; + const cfg = state.config; + const enemySet = new Set(state.bots.filter(b => b.owner !== myID).map(b => posKey(b.position))); + const energySet = new Set(state.energy.map(posKey)); + + return state.bots + .filter(b => b.owner === myID) + .map(b => { + const targets = enemySet.size > 0 ? enemySet : energySet; + const dir = toward(b.position, targets, cfg) ?? DIRS[randInt(4)]; + return { position: b.position, direction: dir }; + }); +} + +export const BUILTIN_STRATEGIES: Record = { + random: randomStrategy, + gatherer: gathererStrategy, + rusher: rusherStrategy, + guardian: guardianStrategy, + swarm: swarmStrategy, + hunter: hunterStrategy, +}; + +// ──────────────────────────────────────────────────────────────────────────── +// Strategy helpers +// ──────────────────────────────────────────────────────────────────────────── + +function toward(from: Position, targets: Set, cfg: Config): Direction | null { + if (targets.size === 0) return null; + let best: Direction | null = null; + let bestD = Infinity; + for (const d of DIRS) { + const np = applyDir(from, d, cfg); + for (const k of targets) { + const [r, c] = k.split(',').map(Number); + const d2 = dist2(np, { row: r, col: c }, cfg); + if (d2 < bestD) { bestD = d2; best = d; } + } + } + return best; +} + +function fleeFrom(from: Position, enemies: Set, cfg: Config): Direction | null { + const thr = cfg.attack_radius2 + 4; + let close = false; + for (const k of enemies) { + const [r, c] = k.split(',').map(Number); + if (dist2(from, { row: r, col: c }, cfg) <= thr) { close = true; break; } + } + if (!close) return null; + let best: Direction | null = null; + let bestD = -1; + for (const d of DIRS) { + const np = applyDir(from, d, cfg); + let minD = Infinity; + for (const k of enemies) { + const [r, c] = k.split(',').map(Number); + const d2 = dist2(np, { row: r, col: c }, cfg); + if (d2 < minD) minD = d2; + } + if (minD > bestD) { bestD = minD; best = d; } + } + return best; +} + +function isNearSet(from: Position, targets: Set, cfg: Config, r2: number): boolean { + for (const k of targets) { + const [r, c] = k.split(',').map(Number); + if (dist2(from, { row: r, col: c }, cfg) <= r2) return true; + } + return false; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Match runner +// ──────────────────────────────────────────────────────────────────────────── + +export interface ReplayTurn { + turn: number; + bots: { id: number; owner: number; position: Position; alive: boolean }[]; + cores: { position: Position; owner: number; active: boolean }[]; + energy: Position[]; + scores: number[]; + energy_held: number[]; + events: GameEvent[]; +} + +export interface Replay { + match_id: string; + config: Config; + result: MatchResult; + players: { id: number; name: string }[]; + map: { rows: number; cols: number; walls: Position[]; cores: { position: Position; owner: number }[]; energy_nodes: Position[] }; + turns: ReplayTurn[]; +} + +export function runMatch( + cfg: Config, + strategy1: BotStrategy | string, + strategy2: BotStrategy | string, + seed?: number, +): { replay: Replay; result: MatchResult } { + const s1 = typeof strategy1 === 'string' ? BUILTIN_STRATEGIES[strategy1] ?? randomStrategy : strategy1; + const s2 = typeof strategy2 === 'string' ? BUILTIN_STRATEGIES[strategy2] ?? randomStrategy : strategy2; + + const gs = newGame(cfg, seed); + + const wallPositions: Position[] = []; + for (const k of gs.walls) { + const [r, c] = k.split(',').map(Number); + wallPositions.push({ row: r, col: c }); + } + + const turns: ReplayTurn[] = []; + + function recordTurn(): ReplayTurn { + return { + turn: gs.turn, + bots: gs.bots.map(b => ({ ...b })), + cores: gs.cores.map(c => ({ ...c })), + energy: gs.energy.filter(e => e.hasEnergy).map(e => e.position), + scores: gs.players.map(p => p.score), + energy_held: gs.players.map(p => p.energy), + events: [...gs.events], + }; + } + + turns.push(recordTurn()); + + let result: MatchResult | null = null; + while (!result) { + const allMoves = new Map(); + for (const p of gs.players) { + const visible = getVisibleState(gs, p.id); + const strategy = p.id === 0 ? s1 : s2; + try { + allMoves.set(p.id, strategy(visible)); + } catch { + allMoves.set(p.id, []); + } + } + result = executeTurn(gs, allMoves); + turns.push(recordTurn()); + } + + const replay: Replay = { + match_id: gs.matchId, + config: cfg, + result, + players: [{ id: 0, name: typeof strategy1 === 'string' ? strategy1 : 'custom' }, + { id: 1, name: typeof strategy2 === 'string' ? strategy2 : 'opponent' }], + map: { + rows: cfg.rows, + cols: cfg.cols, + walls: wallPositions, + cores: gs.cores.map(c => ({ position: c.position, owner: c.owner })), + energy_nodes: gs.energy.map(e => e.position), + }, + turns, + }; + + return { replay, result }; +} diff --git a/web/src/pages/clip-maker.ts b/web/src/pages/clip-maker.ts new file mode 100644 index 0000000..7d22ad7 --- /dev/null +++ b/web/src/pages/clip-maker.ts @@ -0,0 +1,780 @@ +// Clip maker: export replay segments as MP4 (WebM) or animated GIF +// with 5 social media format presets. + +import { ReplayViewer } from '../replay-viewer'; +import type { Replay } from '../types'; + +// ─── Social format presets ─────────────────────────────────────────────────── + +interface SocialPreset { + name: string; + width: number; + height: number; + ratio: string; + icon: string; +} + +const SOCIAL_PRESETS: SocialPreset[] = [ + { name: 'Twitter / X', width: 1280, height: 720, ratio: '16:9', icon: '𝕏' }, + { name: 'Instagram Square', width: 1080, height: 1080, ratio: '1:1', icon: '▣' }, + { name: 'Instagram Story', width: 1080, height: 1920, ratio: '9:16', icon: '◱' }, + { name: 'TikTok / Reels', width: 1080, height: 1920, ratio: '9:16', icon: '▶' }, + { name: 'YouTube Shorts', width: 1080, height: 1920, ratio: '9:16', icon: '▷' }, +]; + +// Preview scale: limit longest side to 360px +function previewDims(preset: SocialPreset): { w: number; h: number } { + const scale = 360 / Math.max(preset.width, preset.height); + return { w: Math.round(preset.width * scale), h: Math.round(preset.height * scale) }; +} + +// ─── Page render ───────────────────────────────────────────────────────────── + +export function renderClipMakerPage(_params: Record): void { + const app = document.getElementById('app'); + if (!app) return; + app.innerHTML = buildHTML(); + requestAnimationFrame(() => initClipMaker()); +} + +function buildHTML(): string { + const presetOptions = SOCIAL_PRESETS.map((p, i) => + ``, + ).join(''); + + return ` +
+

Clip Maker

+

Export replay highlights as MP4 or animated GIF, sized for social media.

+ +
+ +
+
+
Load Replay
+
+ + +
+ + +
+
+ +
+ + + + + + +
+ + +
+ +
+
+
+ + ${CLIP_STYLES} + `; +} + +// ─── Initialisation ─────────────────────────────────────────────────────────── + +function initClipMaker(): void { + let replay: Replay | null = null; + let previewViewer: ReplayViewer | null = null; + let previewCanvas: HTMLCanvasElement | null = null; + + const loadStatus = document.getElementById('clip-load-status')!; + const settingsPanel = document.getElementById('clip-settings-panel')!; + const rangePanel = document.getElementById('clip-range-panel')!; + const exportPanel = document.getElementById('clip-export-panel')!; + const previewPanel = document.getElementById('clip-preview-panel')!; + + const startSlider = document.getElementById('clip-start-slider') as HTMLInputElement; + const endSlider = document.getElementById('clip-end-slider') as HTMLInputElement; + const startVal = document.getElementById('clip-start-val')!; + const endVal = document.getElementById('clip-end-val')!; + const fpsSelect = document.getElementById('clip-fps-select') as HTMLSelectElement; + const presetSelect = document.getElementById('clip-preset-select') as HTMLSelectElement; + const dimsLabel = document.getElementById('preset-dims-label')!; + const previewInfo = document.getElementById('clip-preview-info')!; + const frameLabel = document.getElementById('clip-frame-label')!; + const previewFrame = document.getElementById('clip-preview-frame')!; + + function updateDimsLabel(): void { + const p = SOCIAL_PRESETS[Number(presetSelect.value)]; + dimsLabel.textContent = `${p.width} × ${p.height} px`; + } + updateDimsLabel(); + presetSelect.addEventListener('change', () => { updateDimsLabel(); rebuildPreview(); }); + + function showError(msg: string): void { + loadStatus.textContent = msg; + loadStatus.className = 'clip-status error'; + } + + function loadReplayData(data: Replay): void { + replay = data; + const total = data.turns.length - 1; + + startSlider.max = String(total); + startSlider.value = '0'; + endSlider.max = String(total); + endSlider.value = String(total); + startVal.textContent = '0'; + endVal.textContent = String(total); + + settingsPanel.style.display = ''; + rangePanel.style.display = ''; + exportPanel.style.display = ''; + previewPanel.style.display = ''; + + loadStatus.textContent = `Loaded: ${data.match_id} (${total + 1} turns)`; + loadStatus.className = 'clip-status ok'; + + rebuildPreview(); + } + + function rebuildPreview(): void { + if (!replay) return; + const preset = SOCIAL_PRESETS[Number(presetSelect.value)]; + const dims = previewDims(preset); + previewInfo.textContent = `${preset.width}×${preset.height}`; + + // Build or recreate preview canvas + previewFrame.innerHTML = ''; + previewCanvas = document.createElement('canvas'); + previewFrame.appendChild(previewCanvas); + + // Render the game into a temp canvas, then composite into preview + const tempCanvas = document.createElement('canvas'); + previewViewer = new ReplayViewer(tempCanvas, { cellSize: 8, showGrid: false }); + previewViewer.loadReplay(replay); + + drawCompositeFrame(previewCanvas, tempCanvas, preset, dims, Number(startSlider.value)); + frameLabel.textContent = `Turn ${startSlider.value}`; + } + + startSlider.addEventListener('input', () => { + startVal.textContent = startSlider.value; + if (Number(startSlider.value) > Number(endSlider.value)) { + endSlider.value = startSlider.value; + endVal.textContent = endSlider.value; + } + updatePreviewTurn(Number(startSlider.value)); + }); + + endSlider.addEventListener('input', () => { + endVal.textContent = endSlider.value; + if (Number(endSlider.value) < Number(startSlider.value)) { + startSlider.value = endSlider.value; + startVal.textContent = startSlider.value; + } + }); + + document.getElementById('clip-prev-btn')!.addEventListener('click', () => { + const cur = Number(startSlider.value); + const prev = Math.max(0, cur - 1); + startSlider.value = String(prev); + startVal.textContent = String(prev); + updatePreviewTurn(prev); + }); + + document.getElementById('clip-next-btn')!.addEventListener('click', () => { + const cur = Number(startSlider.value); + const next = Math.min(Number(startSlider.max), cur + 1); + startSlider.value = String(next); + startVal.textContent = String(next); + updatePreviewTurn(next); + }); + + function updatePreviewTurn(turn: number): void { + if (!replay || !previewCanvas || !previewViewer) return; + const preset = SOCIAL_PRESETS[Number(presetSelect.value)]; + const dims = previewDims(preset); + const tempCanvas = document.createElement('canvas'); + const tv = new ReplayViewer(tempCanvas, { cellSize: 8, showGrid: false }); + tv.loadReplay(replay); + tv.setTurn(turn); + drawCompositeFrame(previewCanvas, tempCanvas, preset, dims, turn); + frameLabel.textContent = `Turn ${turn}`; + } + + // ── File load ────────────────────────────────────────────────────────────── + document.getElementById('clip-file-input')!.addEventListener('change', async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + try { + const text = await file.text(); + loadReplayData(JSON.parse(text) as Replay); + } catch (err) { + showError('Failed to parse replay: ' + err); + } + }); + + document.getElementById('clip-load-url-btn')!.addEventListener('click', async () => { + const url = (document.getElementById('clip-url-input') as HTMLInputElement).value.trim(); + if (!url) return; + loadStatus.textContent = 'Loading…'; + loadStatus.className = 'clip-status'; + try { + const resp = await fetch(url); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + loadReplayData((await resp.json()) as Replay); + } catch (err) { + showError('Failed to load URL: ' + err); + } + }); + + // ── MP4 export ──────────────────────────────────────────────────────────── + document.getElementById('clip-export-mp4')!.addEventListener('click', async () => { + if (!replay) return; + await exportVideo(replay, 'mp4'); + }); + + // ── GIF export ──────────────────────────────────────────────────────────── + document.getElementById('clip-export-gif')!.addEventListener('click', async () => { + if (!replay) return; + await exportGIF(replay); + }); + + async function exportVideo(r: Replay, _fmt: string): Promise { + if (!('MediaRecorder' in window)) { + alert('MediaRecorder API not supported in this browser. Please use Chrome or Firefox.'); + return; + } + + const preset = SOCIAL_PRESETS[Number(presetSelect.value)]; + const fps = Number(fpsSelect.value); + const startTurn = Number(startSlider.value); + const endTurn = Number(endSlider.value); + const totalFrames = endTurn - startTurn + 1; + + // Determine preview scale for video (cap at 720p equivalent) + const scale = Math.min(1, 720 / Math.max(preset.width, preset.height)); + const vw = Math.round(preset.width * scale); + const vh = Math.round(preset.height * scale); + + const exportCanvas = document.createElement('canvas'); + exportCanvas.width = vw; + exportCanvas.height = vh; + + const stream = exportCanvas.captureStream(fps); + const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') + ? 'video/webm;codecs=vp9' + : 'video/webm'; + const recorder = new MediaRecorder(stream, { mimeType }); + const chunks: Blob[] = []; + recorder.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); }; + + const tempCanvas = document.createElement('canvas'); + const tv = new ReplayViewer(tempCanvas, { cellSize: 6, showGrid: false }); + tv.loadReplay(r); + + showProgress(0); + recorder.start(); + + const msPerFrame = 1000 / fps; + + for (let t = startTurn; t <= endTurn; t++) { + tv.setTurn(t); + drawCompositeFrame(exportCanvas, tempCanvas, preset, { w: vw, h: vh }, t); + await sleep(msPerFrame); + updateProgress(((t - startTurn) / totalFrames) * 100); + } + + recorder.stop(); + + await new Promise(res => { recorder.onstop = () => res(); }); + + hideProgress(); + const blob = new Blob(chunks, { type: mimeType }); + downloadBlob(blob, `acb-clip-${r.match_id}-${preset.name.replace(/\s+/g, '_')}.webm`); + } + + async function exportGIF(r: Replay): Promise { + const preset = SOCIAL_PRESETS[Number(presetSelect.value)]; + const fps = Number(fpsSelect.value); + const startTurn = Number(startSlider.value); + const endTurn = Number(endSlider.value); + const totalFrames = endTurn - startTurn + 1; + + // Use small scale for GIF to keep file size manageable (max 480px) + const scale = Math.min(1, 480 / Math.max(preset.width, preset.height)); + const gw = Math.round(preset.width * scale); + const gh = Math.round(preset.height * scale); + + const frameCanvas = document.createElement('canvas'); + frameCanvas.width = gw; + frameCanvas.height = gh; + const frameCtx = frameCanvas.getContext('2d')!; + + const tempCanvas = document.createElement('canvas'); + const tv = new ReplayViewer(tempCanvas, { cellSize: 6, showGrid: false }); + tv.loadReplay(r); + + const encoder = new GIFEncoder(gw, gh, fps); + + showProgress(0); + + for (let t = startTurn; t <= endTurn; t++) { + tv.setTurn(t); + drawCompositeFrame(frameCanvas, tempCanvas, preset, { w: gw, h: gh }, t); + const imgData = frameCtx.getImageData(0, 0, gw, gh); + encoder.addFrame(imgData); + updateProgress(((t - startTurn) / totalFrames) * 100); + // Yield to keep browser responsive + if ((t - startTurn) % 5 === 0) await sleep(0); + } + + hideProgress(); + const gif = encoder.encode(); + downloadBlob(new Blob([gif.buffer as ArrayBuffer], { type: 'image/gif' }), `acb-clip-${r.match_id}-${preset.name.replace(/\s+/g, '_')}.gif`); + } + + function showProgress(pct: number): void { + const p = document.getElementById('clip-export-progress')!; + p.classList.remove('hidden'); + setProgress(pct); + } + + function updateProgress(pct: number): void { + setProgress(pct); + } + + function hideProgress(): void { + document.getElementById('clip-export-progress')!.classList.add('hidden'); + } + + function setProgress(pct: number): void { + (document.getElementById('clip-progress-fill') as HTMLElement).style.width = `${pct.toFixed(0)}%`; + (document.getElementById('clip-progress-label') as HTMLElement).textContent = `${pct.toFixed(0)}%`; + } +} + +// ─── Composite frame renderer ───────────────────────────────────────────────── +// Renders a game frame onto a target canvas with the chosen social aspect ratio, +// adding letterbox/pillarbox and a title bar. + +function drawCompositeFrame( + target: HTMLCanvasElement, + gameCanvas: HTMLCanvasElement, + _preset: SocialPreset, + dims: { w: number; h: number }, + turn: number, +): void { + target.width = dims.w; + target.height = dims.h; + + const ctx = target.getContext('2d')!; + ctx.fillStyle = '#0f172a'; + ctx.fillRect(0, 0, dims.w, dims.h); + + // Title bar height (proportional) + const barH = Math.round(dims.h * 0.07); + const barY = dims.h - barH; + + // Game area (keep game canvas aspect ratio, fit inside dims minus bars) + const gameW = gameCanvas.width; + const gameH = gameCanvas.height; + const avW = dims.w; + const avH = dims.h - barH * 2; + + const scale = Math.min(avW / gameW, avH / gameH); + const dw = Math.round(gameW * scale); + const dh = Math.round(gameH * scale); + const dx = Math.round((dims.w - dw) / 2); + const dy = barH + Math.round((avH - dh) / 2); + + ctx.drawImage(gameCanvas, dx, dy, dw, dh); + + // Top bar: title + ctx.fillStyle = 'rgba(15,23,42,0.85)'; + ctx.fillRect(0, 0, dims.w, barH); + + const fontSize = Math.max(10, Math.round(barH * 0.45)); + ctx.fillStyle = '#f8fafc'; + ctx.font = `600 ${fontSize}px -apple-system, sans-serif`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText('AI Code Battle', Math.round(dims.w * 0.03), barH / 2); + + // Bottom bar: turn info + ctx.fillStyle = 'rgba(15,23,42,0.85)'; + ctx.fillRect(0, barY, dims.w, barH); + + ctx.fillStyle = '#94a3b8'; + ctx.font = `${fontSize}px -apple-system, sans-serif`; + ctx.textAlign = 'right'; + ctx.fillText(`Turn ${turn}`, dims.w - Math.round(dims.w * 0.03), barY + barH / 2); +} + +// ─── GIF encoder ───────────────────────────────────────────────────────────── + +class GIFEncoder { + private width: number; + private height: number; + private delay: number; // centiseconds per frame + private palette: Uint8Array; // 256×3 RGB + private frames: Uint8Array[] = []; + + constructor(width: number, height: number, fps: number) { + this.width = width; + this.height = height; + this.delay = Math.round(100 / fps); + this.palette = buildGIFPalette(); + } + + addFrame(imgData: ImageData): void { + const indices = quantizeFrame(imgData, this.palette); + const lzw = lzwEncode(indices, 8); + this.frames.push(lzw); + } + + encode(): Uint8Array { + const out: number[] = []; + + // GIF89a header + for (const c of [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) out.push(c); + + // Logical screen descriptor + out.push(this.width & 0xFF, (this.width >> 8) & 0xFF); + out.push(this.height & 0xFF, (this.height >> 8) & 0xFF); + // Packed: GlobalCT=1, colorRes=7, sort=0, globalCT size=7 (2^8=256 colors) + out.push(0b11110111); + out.push(0); // bg color index + out.push(0); // pixel aspect ratio + + // Global color table (256 × 3 bytes) + for (let i = 0; i < this.palette.length; i++) out.push(this.palette[i]); + + // Netscape looping extension (loop forever) + out.push(0x21, 0xFF, 0x0B); + for (const c of [78,69,84,83,67,65,80,69,50,46,48]) out.push(c); // NETSCAPE2.0 + out.push(0x03, 0x01, 0x00, 0x00, 0x00); // loop count = 0 (infinite) + + // Frames + for (const frame of this.frames) { + // Graphic Control Extension + out.push(0x21, 0xF9, 0x04); + out.push(0b00000100); // disposal: restore to background + out.push(this.delay & 0xFF, (this.delay >> 8) & 0xFF); + out.push(0x00); // transparent color index (none) + out.push(0x00); // block terminator + + // Image Descriptor + out.push(0x2C); + out.push(0, 0, 0, 0); // left, top + out.push(this.width & 0xFF, (this.width >> 8) & 0xFF); + out.push(this.height & 0xFF, (this.height >> 8) & 0xFF); + out.push(0x00); // no local color table, not interlaced + + // LZW minimum code size + out.push(0x08); + + // LZW data in sub-blocks (max 255 bytes each) + let i = 0; + while (i < frame.length) { + const blockSize = Math.min(255, frame.length - i); + out.push(blockSize); + for (let j = 0; j < blockSize; j++) out.push(frame[i + j]); + i += blockSize; + } + out.push(0x00); // block terminator + } + + // GIF trailer + out.push(0x3B); + + return new Uint8Array(out); + } +} + +// Build a 256-color palette: 6×6×6 web-safe cube (216) + 40 game-specific colors +function buildGIFPalette(): Uint8Array { + const buf = new Uint8Array(256 * 3); + let idx = 0; + + // 216 web-safe colors + for (let r = 0; r < 6; r++) { + for (let g = 0; g < 6; g++) { + for (let b = 0; b < 6; b++) { + buf[idx++] = r * 51; + buf[idx++] = g * 51; + buf[idx++] = b * 51; + } + } + } + + // Game-specific dark theme colors + const extra: [number, number, number][] = [ + [15, 23, 42], // bg-primary + [30, 41, 59], // bg-secondary + [51, 65, 85], // bg-tertiary + [71, 85, 105], // border + [248, 250, 252], // text-primary (near white) + [148, 163, 184], // text-muted + [59, 130, 246], // accent blue (player 0) + [239, 68, 68], // error red (player 1) + [34, 197, 94], // success green (energy) + [245, 158, 11], // warning amber + [167, 139, 250], // purple + [96, 165, 250], // light blue core + [248, 113, 113], // light red core + [134, 239, 172], // light green energy + [251, 191, 36], // yellow energy + [17, 24, 39], // very dark bg + [31, 41, 55], // wall color + [55, 65, 81], // grid color + [226, 232, 240], // text-secondary + [100, 116, 139], // slate-500 + [30, 64, 175], // blue-800 + [153, 27, 27], // red-800 + [20, 83, 45], // green-800 + [120, 53, 15], // amber-800 + [109, 40, 217], // violet-700 + [186, 230, 253], // sky-200 + [254, 202, 202], // red-200 + [187, 247, 208], // green-200 + [254, 240, 138], // yellow-200 + [221, 214, 254], // violet-200 + [14, 165, 233], // sky-500 + [236, 72, 153], // pink-500 + [168, 85, 247], // purple-500 + [245, 101, 101], // red-400 + [74, 222, 128], // green-400 + [251, 211, 141], // amber-300 + [147, 197, 253], // blue-300 + [240, 171, 252], // fuchsia-300 + [0, 0, 0], // black + [255, 255, 255], // white + ]; + + for (const [r, g, b] of extra) { + if (idx >= 256 * 3) break; + buf[idx++] = r; + buf[idx++] = g; + buf[idx++] = b; + } + + return buf; +} + +// Map each RGBA pixel to nearest palette index +function quantizeFrame(imgData: ImageData, palette: Uint8Array): Uint8Array { + const { data, width, height } = imgData; + const result = new Uint8Array(width * height); + const numColors = palette.length / 3; + + for (let i = 0; i < width * height; i++) { + const r = data[i * 4]; + const g = data[i * 4 + 1]; + const b = data[i * 4 + 2]; + result[i] = nearestPalette(r, g, b, palette, numColors); + } + return result; +} + +function nearestPalette(r: number, g: number, b: number, palette: Uint8Array, numColors: number): number { + let bestIdx = 0; + let bestDist = 0x7FFFFFFF; + for (let i = 0; i < numColors; i++) { + const dr = r - palette[i * 3]; + const dg = g - palette[i * 3 + 1]; + const db = b - palette[i * 3 + 2]; + const dist = dr * dr + dg * dg + db * db; + if (dist < bestDist) { + bestDist = dist; + bestIdx = i; + if (dist === 0) break; // exact match + } + } + return bestIdx; +} + +// GIF LZW compression (GIF variant, LSB-first bit packing) +function lzwEncode(pixels: Uint8Array, minCodeSize: number): Uint8Array { + const clearCode = 1 << minCodeSize; + const endCode = clearCode + 1; + + let codeSize = minCodeSize + 1; + let nextCode = endCode + 1; + + const output: number[] = []; + let buf = 0; + let bufBits = 0; + + const emit = (code: number) => { + buf |= code << bufBits; + bufBits += codeSize; + while (bufBits >= 8) { + output.push(buf & 0xFF); + buf >>>= 8; + bufBits -= 8; + } + }; + + // Code table: string → code index + const table = new Map(); + + const initTable = () => { + table.clear(); + for (let i = 0; i < clearCode; i++) { + table.set(String.fromCharCode(i), i); + } + nextCode = endCode + 1; + codeSize = minCodeSize + 1; + }; + + initTable(); + emit(clearCode); + + if (pixels.length === 0) { + emit(endCode); + if (bufBits > 0) output.push(buf & 0xFF); + return new Uint8Array(output); + } + + let str = String.fromCharCode(pixels[0]); + + for (let i = 1; i < pixels.length; i++) { + const c = String.fromCharCode(pixels[i]); + const concat = str + c; + + if (table.has(concat)) { + str = concat; + } else { + emit(table.get(str)!); + + if (nextCode < 4096) { + table.set(concat, nextCode++); + // Increase code size when we've exhausted current range + if (nextCode >= (1 << codeSize) && codeSize < 12) { + codeSize++; + } + } else { + // Code table full, emit clear and reset + emit(clearCode); + initTable(); + } + + str = c; + } + } + + emit(table.get(str)!); + emit(endCode); + + if (bufBits > 0) output.push(buf & 0xFF); + + return new Uint8Array(output); +} + +// ─── Utilities ──────────────────────────────────────────────────────────────── + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const CLIP_STYLES = ` + +`; diff --git a/web/src/pages/evolution.ts b/web/src/pages/evolution.ts new file mode 100644 index 0000000..277ab3a --- /dev/null +++ b/web/src/pages/evolution.ts @@ -0,0 +1,592 @@ +// Evolution dashboard - shows live evolution pipeline status + +import { fetchEvolutionData, type EvolutionLiveData, type IslandStat, type LineageNode, type MetaSnapshot, type GenerationEntry } from '../api-types'; + +const ISLAND_COLORS: Record = { + alpha: '#ef4444', // red - core-rushing + beta: '#f59e0b', // amber - energy-focused + gamma: '#22c55e', // green - defensive + delta: '#a78bfa', // violet - experimental +}; + +const ISLAND_LABELS: Record = { + alpha: 'Alpha (Rush)', + beta: 'Beta (Economy)', + gamma: 'Gamma (Defense)', + delta: 'Delta (Experimental)', +}; + +export async function renderEvolutionPage(): Promise { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+

Evolution Dashboard

+
Loading evolution data...
+
+ `; + + const content = document.getElementById('evolution-content'); + if (!content) return; + + try { + const data = await fetchEvolutionData(); + renderDashboard(content, data); + } catch { + content.innerHTML = ` +
+

Evolution data not available yet.

+

The evolution pipeline needs to run at least one cycle before data appears here. + Run acb-evolver live-export to generate the data file.

+
+ `; + } +} + +function renderDashboard(container: HTMLElement, data: EvolutionLiveData): void { + container.innerHTML = ` +

Last updated: ${formatTimestamp(data.updated_at)}  ·  + ${data.total_programs} programs  ·  ${data.promoted_count} promoted

+ +
+

Island Status

+
+
+ +
+

Meta Tracker Best fitness per island over generations

+
+
+ +
+

Lineage Tree Program ancestry (top 80 by fitness)

+
+
+ +
+

Generation Log

+
+
+ + + `; + + renderIslandGrid(document.getElementById('island-grid')!, data.islands); + renderMetaChart(document.getElementById('meta-chart')!, data.meta_snapshots); + renderLineageTree(document.getElementById('lineage-tree')!, data.lineage); + renderGenerationLog(document.getElementById('generation-log')!, data.generation_log); +} + +// ── Island Status ────────────────────────────────────────────────────────────── + +function renderIslandGrid(container: HTMLElement, islands: Record): void { + const islandOrder = ['alpha', 'beta', 'gamma', 'delta']; + const cards = islandOrder.map(island => { + const stat = islands[island]; + if (!stat) return ''; + const color = ISLAND_COLORS[island] ?? '#94a3b8'; + const label = ISLAND_LABELS[island] ?? island; + const diversityPct = Math.round(stat.diversity * 100); + return ` +
+
${escapeHtml(label)}
+
+ Population + ${stat.count} +
+
+ Best Fitness + ${(stat.best_fitness * 100).toFixed(1)}% +
+
+ Avg Fitness + ${(stat.avg_fitness * 100).toFixed(1)}% +
+
+ Promoted + ${stat.promoted_count} +
+
+
+
+
+ Diversity: ${diversityPct}% +
+
+ `; + }); + container.innerHTML = cards.join(''); +} + +// ── Meta Tracker Chart ───────────────────────────────────────────────────────── + +function renderMetaChart(container: HTMLElement, snapshots: MetaSnapshot[]): void { + if (!snapshots || snapshots.length === 0) { + container.innerHTML = '

No generation data yet.

'; + return; + } + + const islands = ['alpha', 'beta', 'gamma', 'delta']; + const W = 700, H = 220; + const padL = 44, padR = 16, padT = 16, padB = 36; + const chartW = W - padL - padR; + const chartH = H - padT - padB; + + const gens = snapshots.map(s => s.generation); + const minGen = gens[0]; + const maxGen = gens[gens.length - 1]; + const genRange = Math.max(maxGen - minGen, 1); + + // Find max count across all islands/snapshots for Y scale + let maxCount = 1; + for (const snap of snapshots) { + for (const island of islands) { + const v = snap.island_counts[island] ?? 0; + if (v > maxCount) maxCount = v; + } + } + + const xOf = (gen: number) => padL + ((gen - minGen) / genRange) * chartW; + const yOf = (v: number) => padT + chartH - (v / maxCount) * chartH; + + const lineEls: string[] = []; + const dotEls: string[] = []; + const legendEls: string[] = []; + + for (const island of islands) { + const color = ISLAND_COLORS[island] ?? '#94a3b8'; + const points = snapshots.map(s => ({ + x: xOf(s.generation), + y: yOf(s.island_counts[island] ?? 0), + })); + + if (points.length < 2) { + // single point — draw a dot + if (points.length === 1) { + dotEls.push(``); + } + } else { + const d = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '); + lineEls.push(``); + for (const p of points) { + dotEls.push(``); + } + } + } + + // Legend + islands.forEach((island, i) => { + const color = ISLAND_COLORS[island] ?? '#94a3b8'; + const lx = padL + i * 120; + const ly = H - 6; + legendEls.push(` + + ${escapeHtml(ISLAND_LABELS[island] ?? island)} + `); + }); + + // Y axis ticks + const yTicks: string[] = []; + const tickCount = 4; + for (let i = 0; i <= tickCount; i++) { + const v = Math.round((maxCount / tickCount) * i); + const y = yOf(v); + yTicks.push(` + + ${v} + `); + } + + // X axis ticks (up to 6) + const xTicks: string[] = []; + const xTickCount = Math.min(6, snapshots.length); + const step = Math.max(1, Math.floor(snapshots.length / xTickCount)); + for (let i = 0; i < snapshots.length; i += step) { + const snap = snapshots[i]; + const x = xOf(snap.generation); + xTicks.push(` + G${snap.generation} + `); + } + + container.innerHTML = ` + + ${yTicks.join('')} + ${xTicks.join('')} + ${lineEls.join('')} + ${dotEls.join('')} + ${legendEls.join('')} + + `; +} + +// ── Lineage Tree ─────────────────────────────────────────────────────────────── + +function renderLineageTree(container: HTMLElement, nodes: LineageNode[]): void { + if (!nodes || nodes.length === 0) { + container.innerHTML = '

No lineage data yet.

'; + return; + } + + // Keep top 80 by fitness to keep the tree readable + const sorted = [...nodes].sort((a, b) => b.fitness - a.fitness).slice(0, 80); + const nodeById = new Map(sorted.map(n => [n.id as unknown as number, n])); + + // Group by generation for Y layout + const genSet = new Set(sorted.map(n => n.generation)); + const gens = Array.from(genSet).sort((a, b) => a - b); + const genIndex = new Map(gens.map((g, i) => [g, i])); + const maxGenIdx = gens.length - 1; + + const NODE_R = 6; + const H_GAP = 38; // horizontal spacing between nodes on same generation + const V_GAP = 54; // vertical spacing between generation rows + const PAD_X = 20; + const PAD_Y = 20; + + // Count nodes per generation for X layout + const nodesPerGen = new Map(); + for (const n of sorted) { + if (!nodesPerGen.has(n.generation)) nodesPerGen.set(n.generation, []); + nodesPerGen.get(n.generation)!.push(n); + } + + // Assign x positions — spread per generation + const nodePos = new Map(); + for (const [gen, genNodes] of nodesPerGen) { + const gIdx = genIndex.get(gen) ?? 0; + const y = PAD_Y + gIdx * V_GAP; + genNodes.forEach((n, i) => { + const x = PAD_X + i * H_GAP; + nodePos.set(n.id as unknown as number, { x, y }); + }); + } + + // SVG dimensions + const svgW = Math.max(...Array.from(nodePos.values()).map(p => p.x)) + PAD_X + NODE_R + 60; + const svgH = PAD_Y + maxGenIdx * V_GAP + PAD_Y + 20; + + const edges: string[] = []; + const nodeEls: string[] = []; + + // Draw edges + for (const n of sorted) { + const pos = nodePos.get(n.id as unknown as number); + if (!pos) continue; + for (const pid of (n.parent_ids ?? [])) { + if (!nodeById.has(pid as unknown as number)) continue; + const ppos = nodePos.get(pid as unknown as number); + if (!ppos) continue; + edges.push(``); + } + } + + // Draw nodes + for (const n of sorted) { + const pos = nodePos.get(n.id as unknown as number); + if (!pos) continue; + const color = ISLAND_COLORS[n.island] ?? '#94a3b8'; + const strokeW = n.promoted ? 2.5 : 1; + const strokeColor = n.promoted ? '#ffffff' : color; + const r = n.promoted ? NODE_R + 2 : NODE_R; + const title = `#${n.id} ${n.island} gen${n.generation} ${n.language} fit=${(n.fitness * 100).toFixed(1)}%${n.promoted ? ' PROMOTED' : ''}`; + nodeEls.push(` + + ${escapeHtml(title)} + + `); + } + + // Generation labels on the left + const genLabels = gens.map(gen => { + const gIdx = genIndex.get(gen) ?? 0; + const y = PAD_Y + gIdx * V_GAP; + return `G${gen}`; + }); + + // Legend + const legendIslands = ['alpha', 'beta', 'gamma', 'delta']; + const legendY = svgH - 4; + const legendEls = legendIslands.map((island, i) => { + const color = ISLAND_COLORS[island] ?? '#94a3b8'; + const lx = PAD_X + i * 110; + return ` + + ${island} + `; + }); + const legendPromo = ` + + promoted + `; + + const fullSvgH = svgH + 20; + + container.innerHTML = ` + + + ${edges.join('')} + ${nodeEls.join('')} + + + ${genLabels.join('')} + + + ${legendEls.join('')} + ${legendPromo} + + + `; +} + +// ── Generation Log Table ─────────────────────────────────────────────────────── + +function renderGenerationLog(container: HTMLElement, log: GenerationEntry[]): void { + if (!log || log.length === 0) { + container.innerHTML = '

No generation history yet.

'; + return; + } + + const rows = log.map(e => { + const color = ISLAND_COLORS[e.island] ?? '#94a3b8'; + const bestPct = (e.best_fitness * 100).toFixed(1); + const avgPct = (e.avg_fitness * 100).toFixed(1); + const barWidth = Math.round(e.best_fitness * 100); + return ` + + ${e.generation} + ${escapeHtml(e.island)} + ${e.count} + ${e.promoted} + +
+ ${bestPct}% +
+
+
+
+ + ${avgPct}% + ${formatTimestamp(e.evaluated_at)} + + `; + }); + + container.innerHTML = ` + + + + + + + + + + + + + + ${rows.join('')} + +
GenIslandProgramsPromotedBest FitnessAvg FitnessTimestamp
+ `; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function formatTimestamp(iso: string): string { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/web/src/pages/feedback.ts b/web/src/pages/feedback.ts new file mode 100644 index 0000000..54ef3eb --- /dev/null +++ b/web/src/pages/feedback.ts @@ -0,0 +1,507 @@ +// Community replay feedback: users annotate replay turns with tags. +// Annotations feed the evolution pipeline by surfacing interesting moments. + +import { fetchMatchIndex, API_BASE, type MatchSummary } from '../api-types'; +import { ReplayViewer } from '../replay-viewer'; +import type { Replay } from '../types'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export const ANNOTATION_TAGS = [ + { id: 'turning_point', label: 'Turning Point', color: '#ef4444', desc: 'A moment that decisively changed the outcome' }, + { id: 'tactical_insight', label: 'Tactical Insight', color: '#3b82f6', desc: 'A clever or instructive strategy in action' }, + { id: 'impressive', label: 'Impressive', color: '#a78bfa', desc: 'Exceptional performance or execution' }, + { id: 'funny', label: 'Funny', color: '#f59e0b', desc: 'An unexpected or humorous sequence' }, + { id: 'bug', label: 'Possible Bug', color: '#f97316', desc: 'Behaviour that looks unintended' }, + { id: 'evolution_seed', label: 'Evolution Seed', color: '#22c55e', desc: 'A sequence worth propagating in the evolution pipeline' }, +]; + +export interface ReplayAnnotation { + match_id: string; + turn: number; + tag: string; + comment: string; + submitted_at: string; +} + +// ─── Page render ───────────────────────────────────────────────────────────── + +export function renderFeedbackPage(_params: Record): void { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = buildHTML(); + requestAnimationFrame(() => initFeedback()); +} + +function buildHTML(): string { + const tagButtons = ANNOTATION_TAGS.map(t => + ``, + ).join(''); + + return ` + + ${FEEDBACK_STYLES} + `; +} + +// ─── Initialisation ─────────────────────────────────────────────────────────── + +function initFeedback(): void { + let replay: Replay | null = null; + let viewer: ReplayViewer | null = null; + let selectedTag: string | null = null; + const localAnnotations: ReplayAnnotation[] = []; + + const loadStatus = document.getElementById('fb-load-status')!; + const formPanel = document.getElementById('annotation-form-panel')!; + const logPanel = document.getElementById('annotations-log-panel')!; + const viewerCol = document.getElementById('fb-viewer-col')!; + const turnNum = document.getElementById('annotate-turn-num')!; + const turnSlider = document.getElementById('ann-turn-slider') as HTMLInputElement; + const canvas = document.getElementById('fb-canvas') as HTMLCanvasElement; + const replayTitle = document.getElementById('fb-replay-title')!; + const replayInfo = document.getElementById('fb-replay-info')!; + const turnLabel = document.getElementById('fb-turn-label')!; + const submitBtn = document.getElementById('submit-annotation-btn') as HTMLButtonElement; + const commentTa = document.getElementById('ann-comment') as HTMLTextAreaElement; + const commentLen = document.getElementById('ann-comment-len')!; + const submitStatus = document.getElementById('submit-status')!; + + // ── Tab switching ────────────────────────────────────────────────────────── + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + const tab = (btn as HTMLElement).dataset.tab!; + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden')); + document.getElementById(`tab-${tab}`)?.classList.remove('hidden'); + }); + }); + + // ── Load recent matches ──────────────────────────────────────────────────── + fetchMatchIndex().then(idx => { + const listEl = document.getElementById('recent-matches-list')!; + const recent = idx.matches.slice(0, 20); + if (recent.length === 0) { + listEl.innerHTML = '
No matches recorded yet.
'; + return; + } + listEl.innerHTML = recent.map(m => ` +
+
${m.participants.map(p => escapeHtml(p.name)).join(' vs ')}
+
+ ${m.turns ?? '?'} turns + ${formatDate(m.completed_at)} +
+
+ `).join(''); + + listEl.querySelectorAll('.recent-match-row').forEach(row => { + row.addEventListener('click', async () => { + const mid = (row as HTMLElement).dataset.matchId!; + const match = recent.find(m => m.id === mid)!; + await loadReplayFromUrl(replayUrlForMatch(match)); + }); + }); + }).catch(() => { + const listEl = document.getElementById('recent-matches-list')!; + listEl.innerHTML = '
Could not load match list.
'; + }); + + // ── File upload ──────────────────────────────────────────────────────────── + document.getElementById('fb-file-input')!.addEventListener('change', async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + try { + const text = await file.text(); + loadReplayData(JSON.parse(text) as Replay); + } catch (err) { + showLoadError('Parse error: ' + err); + } + }); + + // ── URL load ─────────────────────────────────────────────────────────────── + document.getElementById('fb-load-url-btn')!.addEventListener('click', () => { + const url = (document.getElementById('fb-url-input') as HTMLInputElement).value.trim(); + if (url) loadReplayFromUrl(url); + }); + + async function loadReplayFromUrl(url: string): Promise { + loadStatus.textContent = 'Loading…'; + loadStatus.className = 'fb-status'; + try { + const resp = await fetch(url); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + loadReplayData((await resp.json()) as Replay); + } catch (err) { + showLoadError('Failed to load: ' + err); + } + } + + function showLoadError(msg: string): void { + loadStatus.textContent = msg; + loadStatus.className = 'fb-status error'; + } + + function loadReplayData(data: Replay): void { + replay = data; + const total = data.turns.length - 1; + + loadStatus.textContent = `Loaded: ${data.match_id}`; + loadStatus.className = 'fb-status ok'; + + // Setup viewer + viewerCol.style.display = ''; + viewer = new ReplayViewer(canvas, { cellSize: 10, showGrid: false }); + viewer.loadReplay(data); + viewer.onTurnChange = () => updateTurnUI(viewer!.getTurn(), total); + + replayTitle.textContent = 'Replay'; + replayInfo.textContent = `${data.match_id.slice(0, 8)}… · ${total + 1} turns`; + + // Setup annotation form + turnSlider.max = String(total); + turnSlider.value = '0'; + updateTurnUI(0, total); + + formPanel.style.display = ''; + updateAnnotationMarkers(); + + document.getElementById('fb-play-btn')!.addEventListener('click', () => viewer?.togglePlay(), { once: false }); + document.getElementById('fb-reset-btn')!.addEventListener('click', () => { viewer?.pause(); viewer?.setTurn(0); }); + } + + function updateTurnUI(turn: number, total: number): void { + turnNum.textContent = String(turn); + turnSlider.value = String(turn); + turnLabel.textContent = `Turn ${turn} / ${total}`; + } + + // ── Playback controls ────────────────────────────────────────────────────── + turnSlider.addEventListener('input', () => { + const t = Number(turnSlider.value); + viewer?.setTurn(t); + updateTurnUI(t, Number(turnSlider.max)); + }); + + document.getElementById('ann-prev-btn')!.addEventListener('click', () => { + if (!viewer) return; + const t = Math.max(0, viewer.getTurn() - 1); + viewer.setTurn(t); + updateTurnUI(t, Number(turnSlider.max)); + }); + + document.getElementById('ann-next-btn')!.addEventListener('click', () => { + if (!viewer) return; + const t = Math.min(Number(turnSlider.max), viewer.getTurn() + 1); + viewer.setTurn(t); + updateTurnUI(t, Number(turnSlider.max)); + }); + + // ── Tag selection ────────────────────────────────────────────────────────── + document.getElementById('tag-buttons')!.querySelectorAll('.tag-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tag-btn').forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + selectedTag = (btn as HTMLElement).dataset.tag!; + updateSubmitButton(); + }); + }); + + commentTa.addEventListener('input', () => { + const len = commentTa.value.length; + commentLen.textContent = `${len} / 280`; + updateSubmitButton(); + }); + + function updateSubmitButton(): void { + submitBtn.disabled = !selectedTag || !replay; + } + + // ── Submit annotation ────────────────────────────────────────────────────── + submitBtn.addEventListener('click', async () => { + if (!replay || !selectedTag) return; + + const annotation: ReplayAnnotation = { + match_id: replay.match_id, + turn: Number(turnSlider.value), + tag: selectedTag, + comment: commentTa.value.trim(), + submitted_at: new Date().toISOString(), + }; + + submitBtn.disabled = true; + submitStatus.textContent = 'Submitting…'; + submitStatus.className = 'fb-status'; + + try { + // POST to API; gracefully handle 404/offline (store locally) + const resp = await fetch(`${API_BASE}/feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(annotation), + }).catch(() => null); + + if (resp && resp.ok) { + submitStatus.textContent = 'Annotation submitted! Thank you.'; + submitStatus.className = 'fb-status ok'; + } else { + // Store locally if API unavailable + submitStatus.textContent = 'Saved locally (API offline).'; + submitStatus.className = 'fb-status ok'; + } + + localAnnotations.push(annotation); + saveLocalAnnotation(annotation); + + // Reset form + document.querySelectorAll('.tag-btn').forEach(b => b.classList.remove('selected')); + selectedTag = null; + commentTa.value = ''; + commentLen.textContent = '0 / 280'; + updateSubmitButton(); + + logPanel.style.display = ''; + renderAnnotationsLog(localAnnotations); + updateAnnotationMarkers(); + } catch (err) { + submitStatus.textContent = 'Error: ' + err; + submitStatus.className = 'fb-status error'; + } finally { + submitBtn.disabled = !selectedTag; + } + }); + + // Load any previously stored annotations + const stored = loadLocalAnnotations(); + if (stored.length > 0) { + localAnnotations.push(...stored); + logPanel.style.display = ''; + renderAnnotationsLog(localAnnotations); + } + + function updateAnnotationMarkers(): void { + if (!replay) return; + const total = replay.turns.length; + const markersEl = document.getElementById('fb-annotation-markers')!; + const relevant = localAnnotations.filter(a => a.match_id === replay!.match_id); + + if (relevant.length === 0) { + markersEl.innerHTML = ''; + return; + } + + markersEl.innerHTML = relevant.map(a => { + const pct = (a.turn / Math.max(1, total - 1)) * 100; + const tagInfo = ANNOTATION_TAGS.find(t => t.id === a.tag); + const color = tagInfo?.color ?? '#94a3b8'; + return `
`; + }).join(''); + } + + function renderAnnotationsLog(anns: ReplayAnnotation[]): void { + const logEl = document.getElementById('annotations-log')!; + const sorted = [...anns].sort((a, b) => a.turn - b.turn); + logEl.innerHTML = sorted.map(a => { + const tagInfo = ANNOTATION_TAGS.find(t => t.id === a.tag); + return ` +
+ ${escapeHtml(tagInfo?.label ?? a.tag)} + Turn ${a.turn} + ${a.comment ? `${escapeHtml(a.comment)}` : ''} + ${a.match_id.slice(0, 8)}… +
+ `; + }).join(''); + } +} + +// ─── Local storage for offline annotations ──────────────────────────────────── + +const LS_KEY = 'acb_annotations'; + +function saveLocalAnnotation(ann: ReplayAnnotation): void { + try { + const existing: ReplayAnnotation[] = JSON.parse(localStorage.getItem(LS_KEY) ?? '[]'); + existing.push(ann); + localStorage.setItem(LS_KEY, JSON.stringify(existing.slice(-200))); // keep last 200 + } catch {} +} + +function loadLocalAnnotations(): ReplayAnnotation[] { + try { + return JSON.parse(localStorage.getItem(LS_KEY) ?? '[]'); + } catch { + return []; + } +} + +// ─── Utilities ──────────────────────────────────────────────────────────────── + +function replayUrlForMatch(m: MatchSummary): string { + // Replays are stored in R2 at /replays/{match_id}.json + return `/replays/${m.id}.json`; +} + +function formatDate(s: string | null): string { + if (!s) return '–'; + return new Date(s).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const FEEDBACK_STYLES = ` + +`; diff --git a/web/src/pages/rivalries.ts b/web/src/pages/rivalries.ts new file mode 100644 index 0000000..07a5fee --- /dev/null +++ b/web/src/pages/rivalries.ts @@ -0,0 +1,341 @@ +// Rivalries page: detect head-to-head rivalries from match data and +// render narrative cards with template-generated storylines. + +import { fetchMatchIndex, fetchLeaderboard, type MatchSummary } from '../api-types'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface Rivalry { + bot0Id: string; + bot0Name: string; + bot1Id: string; + bot1Name: string; + totalMatches: number; + bot0Wins: number; + bot1Wins: number; + draws: number; + lastMatchAt: string; + rivalryScore: number; // higher = more intense (frequent + close) + narrative: string; + streak: { bot: string; count: number } | null; // current win streak +} + +// ─── Page render ───────────────────────────────────────────────────────────── + +export async function renderRivalriesPage(_params: Record): Promise { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+

Rivalries

+

Head-to-head storylines from the most contested matchups on the grid.

+
Analysing match history…
+
+ ${RIVALRY_STYLES} + `; + + const content = document.getElementById('rivalries-content')!; + + try { + const [matchIdx, leaderboard] = await Promise.all([ + fetchMatchIndex().catch(() => ({ matches: [], updated_at: '' })), + fetchLeaderboard().catch(() => ({ entries: [], updated_at: '' })), + ]); + + const nameMap = new Map(); + for (const e of leaderboard.entries) nameMap.set(e.bot_id, e.name); + + const rivalries = detectRivalries(matchIdx.matches, nameMap); + + if (rivalries.length === 0) { + content.innerHTML = ` +
+

No rivalries detected yet.

+

Rivalries appear when two bots have played at least 3 head-to-head matches. + Check back after more matches have been recorded.

+
+ `; + return; + } + + renderRivalryCards(content, rivalries); + } catch (err) { + content.innerHTML = `
Failed to load rivalry data: ${err}
`; + } +} + +// ─── Rivalry detection ──────────────────────────────────────────────────────── + +function detectRivalries(matches: MatchSummary[], nameMap: Map): Rivalry[] { + // Accumulate head-to-head records between every bot pair + type PairKey = string; + interface PairRecord { + bot0: string; + bot1: string; + wins0: number; + wins1: number; + draws: number; + lastAt: string; + matchIds: string[]; + lastWinner: string | null; + currentStreak: number; // positive = bot0 streak, negative = bot1 streak + } + + const pairMap = new Map(); + + const pairKey = (a: string, b: string): PairKey => + a < b ? `${a}||${b}` : `${b}||${a}`; + + const sortedMatches = [...matches].sort( + (a, b) => new Date(a.completed_at ?? 0).getTime() - new Date(b.completed_at ?? 0).getTime(), + ); + + for (const m of sortedMatches) { + if (m.participants.length < 2) continue; + const [p0, p1] = m.participants; + const key = pairKey(p0.bot_id, p1.bot_id); + + let rec = pairMap.get(key); + if (!rec) { + // Canonicalize: alphabetically first bot_id is bot0 + const [b0, b1] = p0.bot_id < p1.bot_id ? [p0, p1] : [p1, p0]; + rec = { bot0: b0.bot_id, bot1: b1.bot_id, wins0: 0, wins1: 0, draws: 0, lastAt: '', matchIds: [], lastWinner: null, currentStreak: 0 }; + pairMap.set(key, rec); + } + + rec.matchIds.push(m.id); + rec.lastAt = m.completed_at ?? rec.lastAt; + + const winner = m.winner_id; + if (!winner) { + rec.draws++; + rec.currentStreak = 0; + rec.lastWinner = null; + } else if (winner === rec.bot0) { + rec.wins0++; + rec.currentStreak = rec.lastWinner === rec.bot0 ? rec.currentStreak + 1 : 1; + rec.lastWinner = rec.bot0; + } else { + rec.wins1++; + rec.currentStreak = rec.lastWinner === rec.bot1 ? rec.currentStreak - 1 : -1; + rec.lastWinner = rec.bot1; + } + } + + const rivalries: Rivalry[] = []; + + for (const rec of pairMap.values()) { + const total = rec.wins0 + rec.wins1 + rec.draws; + if (total < 3) continue; // minimum threshold for a rivalry + + const closeness = 1 - Math.abs(rec.wins0 - rec.wins1) / Math.max(1, total); + const rivalryScore = total * closeness; + + const bot0Name = nameMap.get(rec.bot0) ?? rec.bot0.slice(0, 8); + const bot1Name = nameMap.get(rec.bot1) ?? rec.bot1.slice(0, 8); + + let streak: Rivalry['streak'] = null; + if (Math.abs(rec.currentStreak) >= 2) { + streak = { + bot: rec.currentStreak > 0 ? bot0Name : bot1Name, + count: Math.abs(rec.currentStreak), + }; + } + + rivalries.push({ + bot0Id: rec.bot0, + bot0Name, + bot1Id: rec.bot1, + bot1Name, + totalMatches: total, + bot0Wins: rec.wins0, + bot1Wins: rec.wins1, + draws: rec.draws, + lastMatchAt: rec.lastAt, + rivalryScore, + narrative: buildNarrative({ + bot0Name, bot1Name, total, + wins0: rec.wins0, wins1: rec.wins1, draws: rec.draws, + streak, + }), + streak, + }); + } + + // Sort by rivalry score (most intense first) + rivalries.sort((a, b) => b.rivalryScore - a.rivalryScore); + + return rivalries.slice(0, 20); // top 20 +} + +// ─── Template narrative builder ─────────────────────────────────────────────── + +interface NarrativeVars { + bot0Name: string; + bot1Name: string; + total: number; + wins0: number; + wins1: number; + draws: number; + streak: { bot: string; count: number } | null; +} + +function buildNarrative(v: NarrativeVars): string { + const leading = v.wins0 >= v.wins1 ? v.bot0Name : v.bot1Name; + const trailing = v.wins0 >= v.wins1 ? v.bot1Name : v.bot0Name; + const leadWins = Math.max(v.wins0, v.wins1); + const trailWins = Math.min(v.wins0, v.wins1); + const winRate = leadWins / Math.max(1, v.total); + + if (Math.abs(v.wins0 - v.wins1) === 0) { + // Perfect tie + return pickTemplate(TIED_NARRATIVES, { ...v, leading, trailing }); + } else if (winRate >= 0.75) { + // Dominant + return pickTemplate(DOMINANT_NARRATIVES, { ...v, leading, trailing, leadWins, trailWins }); + } else if (v.streak && v.streak.count >= 3) { + // Streak + return pickTemplate(STREAK_NARRATIVES, { ...v, leading, trailing, streakBot: v.streak.bot, streakCount: v.streak.count }); + } else { + // Close contest + return pickTemplate(CLOSE_NARRATIVES, { ...v, leading, trailing, leadWins, trailWins }); + } +} + +function pickTemplate(templates: string[], vars: Record): string { + const tmpl = templates[Math.floor(Math.random() * templates.length)]; + return tmpl.replace(/\{(\w+)\}/g, (_, k) => String(vars[k] ?? `{${k}}`)); +} + +const TIED_NARRATIVES = [ + "{bot0Name} and {bot1Name} are locked in perfect equilibrium after {total} clashes — every victory answered in kind.", + "The grid cannot separate {bot0Name} from {bot1Name}. After {total} battles, honours remain exactly even.", + "{bot0Name} vs {bot1Name}: {total} encounters, zero separation. The ultimate standoff continues.", + "Neither {bot0Name} nor {bot1Name} can claim the edge in their {total}-match duel. This rivalry defines balance.", +]; + +const DOMINANT_NARRATIVES = [ + "{leading} has established clear dominance over {trailing}, leading {leadWins}–{trailWins} across {total} meetings.", + "In {total} encounters, {leading} has proven superior to {trailing} with a commanding {leadWins}–{trailWins} record.", + "{trailing} continues its search for answers against {leading}, who holds a decisive {leadWins}–{trailWins} advantage.", + "{leading}'s {leadWins}–{trailWins} record against {trailing} speaks volumes — a rivalry that reads like a masterclass.", +]; + +const STREAK_NARRATIVES = [ + "{streakBot} has won {streakCount} straight against its rival. The momentum in this matchup has shifted dramatically.", + "A {streakCount}-match winning streak for {streakBot} — {leading} and {trailing} are no longer evenly matched.", + "{streakBot} is on fire, rolling off {streakCount} consecutive wins in this heated rivalry.", + "Can anyone stop {streakBot}? A {streakCount}-match streak in their rivalry says the answer, for now, is no.", +]; + +const CLOSE_NARRATIVES = [ + "{leading} holds a slim {leadWins}–{trailWins} edge over {trailing} after {total} closely contested matches.", + "Just {leadWins} vs {trailWins} separates {leading} from {trailing} across {total} grid battles. Every match matters.", + "The {bot0Name}–{bot1Name} rivalry is defined by razor-thin margins: {leadWins} wins to {trailWins} after {total} encounters.", + "{leading} leads {trailing} {leadWins}–{trailWins} but the gap could close in a single session — that's what makes this rivalry great.", +]; + +// ─── Card renderer ──────────────────────────────────────────────────────────── + +function renderRivalryCards(container: HTMLElement, rivalries: Rivalry[]): void { + const dateStr = (s: string) => { + if (!s) return '–'; + return new Date(s).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + }; + + container.innerHTML = ` +
+ ${rivalries.map((r, i) => ` +
+ ${i === 0 ? '
Top Rivalry
' : ''} +
+
+ ${escapeHtml(r.bot0Name)} + ${r.bot0Wins}W +
+
+ VS + ${r.totalMatches} matches +
+
+ ${escapeHtml(r.bot1Name)} + ${r.bot1Wins}W +
+
+ +
+ ${buildWinBar(r)} +
+ +

${escapeHtml(r.narrative)}

+ + +
+ `).join('')} +
+ `; +} + +function buildWinBar(r: Rivalry): string { + const total = r.totalMatches; + const pct0 = total > 0 ? (r.bot0Wins / total) * 100 : 50; + const pctD = total > 0 ? (r.draws / total) * 100 : 0; + const pct1 = 100 - pct0 - pctD; + return ` +
+
+
+
+
+
+ ${r.bot0Wins}W (${pct0.toFixed(0)}%) + ${r.draws > 0 ? r.draws + ' draws' : ''} + ${pct1.toFixed(0)}% (${r.bot1Wins}W) +
+ `; +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const RIVALRY_STYLES = ` + +`; diff --git a/web/src/pages/sandbox.ts b/web/src/pages/sandbox.ts new file mode 100644 index 0000000..bc5fb47 --- /dev/null +++ b/web/src/pages/sandbox.ts @@ -0,0 +1,543 @@ +// In-browser bot sandbox: Monaco editor + TS game engine + WASM upload + replay viewer +import { runMatch, defaultConfig, type Config, type BotStrategy, type VisibleState, type Move } from '../engine'; +import { ReplayViewer } from '../replay-viewer'; +import type { Replay } from '../types'; + +const WASM_BOT_SPEC = `// ACB WASM Bot Interface Spec (v1.0) +// ───────────────────────────────────────────────────────────────────────────── +// Your WASM file must export a global \`acbBot\` object with two functions: +// +// acbBot.init(configJSON: string): void +// Called once before the match starts. Receives the game Config as JSON. +// +// acbBot.compute_moves(stateJSON: string): string +// Called each turn. Receives a VisibleState JSON string; must return a +// JSON array of Move objects: [{"position":{"row":r,"col":c},"direction":"N"}] +// +// VisibleState schema: +// { match_id, turn, config, you:{id,energy,score}, +// bots:[{position,owner}], energy:[{row,col}], +// cores:[{position,owner,active}], walls:[{row,col}], dead:[] } +// +// Config schema: +// { rows, cols, max_turns, vision_radius2, attack_radius2, +// spawn_cost, energy_interval } +// +// Move schema: +// { position:{row,col}, direction:"N"|"E"|"S"|"W"|"" } +// +// Build with: GOOS=js GOARCH=wasm go build -o mybot.wasm ./cmd/mybot/ +// See docs/wasm-bot-interface.md for full examples. +`; + +const STARTER_CODE = `// Starter bot – modify this code, then click "Run Match" +// The function receives a VisibleState and must return Move[] + +function computeMoves(state) { + const myID = state.you.id; + const cfg = state.config; + + return state.bots + .filter(b => b.owner === myID) + .map(b => { + // Find nearest energy + let bestDir = ['N','E','S','W'][Math.floor(Math.random() * 4)]; + let bestDist = Infinity; + + for (const e of state.energy) { + for (const dir of ['N','E','S','W']) { + const np = applyDir(b.position, dir, cfg); + const d = dist2(np, e, cfg); + if (d < bestDist) { bestDist = d; bestDir = dir; } + } + } + + return { position: b.position, direction: bestDir }; + }); +} + +// Helpers (available in sandbox) +function applyDir(p, dir, cfg) { + const deltas = { N:[-1,0], S:[1,0], E:[0,1], W:[0,-1] }; + const [dr, dc] = deltas[dir] ?? [0, 0]; + return { + row: ((p.row + dr) % cfg.rows + cfg.rows) % cfg.rows, + col: ((p.col + dc) % cfg.cols + cfg.cols) % cfg.cols + }; +} +function dist2(a, b, cfg) { + let dr = Math.abs(a.row - b.row); let dc = Math.abs(a.col - b.col); + if (dr > cfg.rows/2) dr = cfg.rows - dr; + if (dc > cfg.cols/2) dc = cfg.cols - dc; + return dr*dr + dc*dc; +} +`; + +export function renderSandboxPage(_params: Record): void { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = buildHTML(); + + // Defer heavy init to avoid blocking render + requestAnimationFrame(() => initSandbox()); +} + +function buildHTML(): string { + return ` +
+

Bot Sandbox

+

Write JavaScript bot logic, pick an opponent, and run an in-browser match instantly — no server required.

+ +
+ +
+
+
+ Bot Code +
+ + + +
+
+
+ +
+ +
+
WASM Bot Interface Spec + +
+ +
+
+ + +
+
+
Match Settings
+
+ + + + + + + + + + + + 100ms +
+ +
+ + + + +
+
+ + + +
+ ${SANDBOX_STYLES} + `; +} + +function initSandbox(): void { + let monacoEditor: any = null; + let currentCode = STARTER_CODE; + let wasmStrategy: BotStrategy | null = null; + let lastReplay: any = null; + let viewer: ReplayViewer | null = null; + + // ── Monaco editor ─────────────────────────────────────────────────────── + loadMonaco().then(monaco => { + monacoEditor = monaco.editor.create( + document.getElementById('monaco-container')!, + { + value: STARTER_CODE, + language: 'javascript', + theme: 'vs-dark', + minimap: { enabled: false }, + fontSize: 13, + lineNumbers: 'on', + scrollBeyondLastLine: false, + automaticLayout: true, + wordWrap: 'on', + }, + ); + monacoEditor.onDidChangeModelContent(() => { + currentCode = monacoEditor.getValue(); + }); + }).catch(() => { + // Monaco unavailable – use plain textarea fallback + const container = document.getElementById('monaco-container')!; + container.innerHTML = ``; + const ta = document.getElementById('code-textarea') as HTMLTextAreaElement; + ta.addEventListener('input', () => { currentCode = ta.value; }); + }); + + // ── WASM upload ───────────────────────────────────────────────────────── + document.getElementById('wasm-upload-btn')!.addEventListener('click', () => { + document.getElementById('wasm-file-input')!.click(); + }); + + document.getElementById('wasm-file-input')!.addEventListener('change', async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + const status = document.getElementById('wasm-status')!; + status.textContent = `Loading ${file.name}…`; + status.className = 'wasm-status'; + + try { + wasmStrategy = await loadWasmBot(file); + status.textContent = `WASM bot loaded: ${file.name}`; + status.className = 'wasm-status ok'; + (document.getElementById('run-btn') as HTMLButtonElement).textContent = 'Run Match (WASM)'; + } catch (err) { + status.textContent = `Failed to load WASM: ${err}`; + status.className = 'wasm-status error'; + } + }); + + // ── Spec toggle ───────────────────────────────────────────────────────── + document.getElementById('toggle-spec-btn')!.addEventListener('click', () => { + const spec = document.getElementById('wasm-spec')!; + const btn = document.getElementById('toggle-spec-btn')!; + spec.classList.toggle('hidden'); + btn.textContent = spec.classList.contains('hidden') ? 'Show' : 'Hide'; + }); + + // ── Reset code ────────────────────────────────────────────────────────── + document.getElementById('reset-code-btn')!.addEventListener('click', () => { + currentCode = STARTER_CODE; + if (monacoEditor) monacoEditor.setValue(STARTER_CODE); + wasmStrategy = null; + const status = document.getElementById('wasm-status')!; + status.className = 'wasm-status hidden'; + (document.getElementById('run-btn') as HTMLButtonElement).textContent = 'Run Match'; + }); + + // ── Speed slider ──────────────────────────────────────────────────────── + document.getElementById('speed-slider')!.addEventListener('input', (e) => { + const val = (e.target as HTMLInputElement).value; + document.getElementById('speed-label')!.textContent = `${val}ms`; + if (viewer) viewer.setSpeed(Number(val)); + }); + + // ── Run match ─────────────────────────────────────────────────────────── + document.getElementById('run-btn')!.addEventListener('click', async () => { + const btn = document.getElementById('run-btn') as HTMLButtonElement; + btn.disabled = true; + btn.textContent = 'Running…'; + + try { + const opponent = (document.getElementById('opponent-select') as HTMLSelectElement).value; + const gridSize = Number((document.getElementById('grid-size-select') as HTMLSelectElement).value); + const maxTurns = Number((document.getElementById('max-turns-select') as HTMLSelectElement).value); + + const cfg: Config = { + ...defaultConfig(), + rows: gridSize, + cols: gridSize, + max_turns: maxTurns, + }; + + // Build user strategy from code or WASM + const userStrategy: BotStrategy = wasmStrategy ?? buildUserStrategy(currentCode); + + const t0 = performance.now(); + const { replay, result } = runMatch(cfg, userStrategy, opponent); + const elapsed = performance.now() - t0; + + lastReplay = replay; + + // Show result + const resultPanel = document.getElementById('result-panel')!; + resultPanel.style.display = ''; + document.getElementById('match-result')!.innerHTML = formatResult(result, replay); + + // Performance panel + const perfPanel = document.getElementById('performance-panel')!; + perfPanel.style.display = ''; + document.getElementById('perf-stats')!.innerHTML = ` +
Match duration (JS)${elapsed.toFixed(1)} ms
+
Turns played${result.turns}
+
Your bots alive${result.bots_alive[0]}
+
Opponent bots alive${result.bots_alive[1]}
+ `; + + // Show replay + document.getElementById('replay-section')!.style.display = ''; + initReplayViewer(replay as any); + + } catch (err) { + alert('Error running match: ' + err); + } finally { + btn.disabled = false; + btn.textContent = wasmStrategy ? 'Run Match (WASM)' : 'Run Match'; + } + }); + + // ── Download replay ───────────────────────────────────────────────────── + document.getElementById('download-replay-btn')?.addEventListener('click', () => { + if (!lastReplay) return; + const blob = new Blob([JSON.stringify(lastReplay, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `sandbox-replay-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(url); + }); + + function initReplayViewer(replay: Replay): void { + const canvas = document.getElementById('sandbox-canvas') as HTMLCanvasElement; + const speed = Number((document.getElementById('speed-slider') as HTMLInputElement).value); + viewer = new ReplayViewer(canvas, { cellSize: 12 }); + viewer.loadReplay(replay); + viewer.setSpeed(speed); + + const turnDisplay = document.getElementById('sb-turn-display')!; + const totalTurns = document.getElementById('sb-total-turns')!; + const slider = document.getElementById('sb-turn-slider') as HTMLInputElement; + const eventsDiv = document.getElementById('sb-events')!; + + totalTurns.textContent = String(viewer.getTotalTurns()); + slider.max = String(viewer.getTotalTurns() - 1); + + viewer.onTurnChange = () => { + turnDisplay.textContent = String(viewer!.getTurn()); + slider.value = String(viewer!.getTurn()); + const events = viewer!.getTurnEvents(); + eventsDiv.innerHTML = events.length === 0 + ? '
No events
' + : events.map(ev => `
${ev.type.replace(/_/g,' ')}
`).join(''); + }; + + document.getElementById('sb-play-btn')!.addEventListener('click', () => viewer!.togglePlay()); + document.getElementById('sb-prev-btn')!.addEventListener('click', () => { viewer!.setTurn(viewer!.getTurn() - 1); }); + document.getElementById('sb-next-btn')!.addEventListener('click', () => { viewer!.setTurn(viewer!.getTurn() + 1); }); + document.getElementById('sb-reset-btn')!.addEventListener('click', () => { viewer!.pause(); viewer!.setTurn(0); }); + + slider.addEventListener('input', () => viewer!.setTurn(parseInt(slider.value, 10))); + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// User strategy builder (sandboxed eval) +// ──────────────────────────────────────────────────────────────────────────── + +function buildUserStrategy(code: string): BotStrategy { + // Wrap the user's computeMoves function; catch errors gracefully + return (state: VisibleState): Move[] => { + try { + // Create a sandboxed function using the user code + const fn = new Function('state', ` + ${code} + if (typeof computeMoves !== 'function') { + throw new Error('computeMoves function not found'); + } + return computeMoves(state); + `); + const result = fn(state); + if (!Array.isArray(result)) return []; + return result as Move[]; + } catch (err) { + console.warn('User strategy error:', err); + return []; + } + }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// WASM bot loader +// ──────────────────────────────────────────────────────────────────────────── + +async function loadWasmBot(file: File): Promise { + const buffer = await file.arrayBuffer(); + + // Try to instantiate the WASM module + let acbBotExport: { init: (c: string) => void; compute_moves: (s: string) => string } | null = null; + + try { + // Standard WASM (non-Go) + const { instance } = await WebAssembly.instantiate(buffer, { + env: { memory: new WebAssembly.Memory({ initial: 256 }) }, + }); + acbBotExport = { + init: (instance.exports.init as (c: string) => void) ?? (() => {}), + compute_moves: instance.exports.compute_moves as (s: string) => string, + }; + } catch { + // Likely a Go WASM – requires wasm_exec.js runtime + // Check if Go runtime is available + if (typeof (globalThis as any).Go !== 'undefined') { + const go = new (globalThis as any).Go(); + const { instance } = await WebAssembly.instantiate(buffer, go.importObject); + go.run(instance); + // After go.run, acbBot global should be set + acbBotExport = (globalThis as any).acbBot; + } else { + throw new Error('Go WASM runtime not loaded. Add