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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-29 01:13:23 -04:00
parent 875ccdbe83
commit f5d7553f98
36 changed files with 6903 additions and 382 deletions

7
.gitignore vendored
View file

@ -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

236
cmd/acb-api/predictions.go Normal file
View file

@ -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,
})
}

248
cmd/acb-api/seasons.go Normal file
View file

@ -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})
}

279
cmd/acb-api/series.go Normal file
View file

@ -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,
})
}

View file

@ -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
}

View file

@ -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 <init-schema|seed|stats|validate|validation-stats|evaluate|retire>")
fmt.Fprintln(os.Stderr, "usage: acb-evolver <init-schema|seed|stats|validate|validation-stats|evaluate|retire|live-export>")
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 {

View file

@ -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,
};
}

View file

@ -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<string, ExportBot>(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<string, number>();
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
? `<h3>Biggest Upset</h3>
<p>${stats.biggest_upset}.</p>`
: '';
const evoSection = stats.island_leader
? `<h3>Evolution Observatory</h3>
<p>Island <strong>${stats.island_leader}</strong> leads the evolution pipeline this week.</p>`
: '';
const body_html = `
<h2>Overview</h2>
<p>
The week of <strong>${weekLabel}</strong> produced <strong>${stats.matches_played}</strong> completed matches
on the AI Code Battle platform.
</p>
<h3>Leaderboard Snapshot</h3>
<p>
<strong>${stats.top_bot}</strong> holds the top position with a rating of
<strong>${stats.top_bot_rating}</strong>. The competition remains fierce as bots jockey
for position in the weekly rankings.
</p>
<h3>Most Active Competitor</h3>
<p>
<strong>${stats.most_active_bot}</strong> played the most matches this week
(<strong>${stats.most_active_bot_matches}</strong> games), demonstrating consistent
availability and aggressive scheduling.
</p>
${upsetSection}
${evoSection}
<h3>What to Watch</h3>
<p>
With the meta always shifting, next week promises fresh rivalries and strategy evolution.
Keep an eye on the <a href="#/evolution">Evolution Dashboard</a> for emerging program
lineages and the <a href="#/rivalries">Rivalries</a> page for head-to-head trends.
</p>
`.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 <h2>, <h3>, <p> tags; no <html>/<body>/<head>)
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<BlogPost> {
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;
}

View file

@ -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<string, number>;
island_best_fitness: Record<string, number>;
}
export interface EvolutionLiveData {
updated_at: string;
total_programs: number;
promoted_count: number;
islands: Record<string, EvolutionIslandStat>;
generation_log: EvolutionGenerationEntry[];
lineage: EvolutionLineageNode[];
meta_snapshots: EvolutionMetaSnapshot[];
}

View file

@ -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<void> {
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<string, BotProfile>;
matchIndex: MatchIndex;
evolutionLive?: EvolutionLiveData;
}): Promise<void> {
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`);
}
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

15
cmd/acb-wasm/bots.go Normal file
View file

@ -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)
}

46
cmd/acb-wasm/build.sh Executable file
View file

@ -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"

210
cmd/acb-wasm/main.go Normal file
View file

@ -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
}

View file

@ -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
}

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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())

View file

@ -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)

View file

@ -678,8 +678,13 @@
<a href="#/leaderboard" class="nav-link">Leaderboard</a>
<a href="#/matches" class="nav-link">Matches</a>
<a href="#/bots" class="nav-link">Bots</a>
<a href="#/evolution" class="nav-link">Evolution</a>
<a href="#/rivalries" class="nav-link">Rivalries</a>
<a href="#/sandbox" class="nav-link">Sandbox</a>
<a href="#/clip-maker" class="nav-link">Clip Maker</a>
<a href="#/feedback" class="nav-link">Feedback</a>
<a href="#/register" class="nav-link">Register</a>
<a href="#/replay" class="nav-link">Replay Viewer</a>
<a href="#/replay" class="nav-link">Replay</a>
</div>
</div>
</nav>

View file

@ -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<string, number>;
island_best_fitness: Record<string, number>;
}
export interface EvolutionLiveData {
updated_at: string;
total_programs: number;
promoted_count: number;
islands: Record<string, IslandStat>;
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<RegisterRes
return response.json();
}
export async function fetchEvolutionData(): Promise<EvolutionLiveData> {
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<BlogIndex> {
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<BlogPost> {
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<RegisterResponse> {
const response = await fetch(`${API_BASE}/rotate-key`, {
method: 'POST',

View file

@ -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);

283
web/src/commentary.ts Normal file
View file

@ -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, any>): 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 = `
<div class="commentary-panel">
<p class="commentary-intro">${escapeHtml(commentary.intro)}</p>
<div class="commentary-feed">
${lines.slice(-10).reverse().map(l => `
<div class="commentary-line importance-${l.importance}">
<span class="commentary-turn">Turn ${l.turn}</span>
<span class="commentary-text">${escapeHtml(l.text)}</span>
</div>
`).join('')}
</div>
${currentTurn !== undefined && currentTurn >= (commentary.lines[commentary.lines.length - 1]?.turn ?? 0) - 5
? `<p class="commentary-outro">${escapeHtml(commentary.outro)}</p>` : ''}
</div>
`;
}
export const COMMENTARY_STYLES = `
<style>
.commentary-panel { font-size: 0.875rem; }
.commentary-intro { color: var(--text-muted); margin-bottom: 12px; font-style: italic; }
.commentary-outro { color: var(--text-primary); margin-top: 12px; font-weight: 600; }
.commentary-feed { display: flex; flex-direction: column; gap: 6px; }
.commentary-line { display: flex; gap: 10px; padding: 6px 10px; border-radius: 4px; border-left: 3px solid transparent; }
.commentary-line.importance-high { background: rgba(59,130,246,0.1); border-color: var(--accent); }
.commentary-line.importance-medium { background: rgba(245,158,11,0.08); border-color: var(--warning); }
.commentary-line.importance-low { background: var(--bg-primary); border-color: var(--bg-tertiary); }
.commentary-turn { color: var(--text-muted); min-width: 52px; font-size: 0.75rem; padding-top: 2px; }
.commentary-text { color: var(--text-secondary); flex: 1; }
</style>
`;
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ────────────────────────────────────────────────────────────────────────────
// 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.",
];

687
web/src/engine.ts Normal file
View file

@ -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<string>; // "row,col"
events: GameEvent[];
dominance: Map<number, number>;
}
// ────────────────────────────────────────────────────────────────────────────
// 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<string>; 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<string>();
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<string>();
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<number, Move[]>): MatchResult | null {
gs.turn++;
gs.events = [];
// Flatten moves: position key -> direction
const moveMap = new Map<string, Direction>();
for (const [, moves] of allMoves) {
for (const m of moves) {
moveMap.set(posKey(m.position), m.direction);
}
}
// Phase 1: Movement
const intended = new Map<number, Position>(); // bot id -> dest
const destBots = new Map<string, Bot[]>();
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<number>();
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<string, EnergyNode>();
for (const en of gs.energy) {
if (en.hasEnergy) energyMap.set(posKey(en.position), en);
}
const botsOnEnergy = new Map<string, Bot[]>();
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<string, BotStrategy> = {
random: randomStrategy,
gatherer: gathererStrategy,
rusher: rusherStrategy,
guardian: guardianStrategy,
swarm: swarmStrategy,
hunter: hunterStrategy,
};
// ────────────────────────────────────────────────────────────────────────────
// Strategy helpers
// ────────────────────────────────────────────────────────────────────────────
function toward(from: Position, targets: Set<string>, 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<string>, 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<string>, 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<number, Move[]>();
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 };
}

780
web/src/pages/clip-maker.ts Normal file
View file

@ -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<string, string>): void {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = buildHTML();
requestAnimationFrame(() => initClipMaker());
}
function buildHTML(): string {
const presetOptions = SOCIAL_PRESETS.map((p, i) =>
`<option value="${i}">${p.icon} ${p.name} (${p.ratio})</option>`,
).join('');
return `
<div class="clip-page">
<h1 class="page-title">Clip Maker</h1>
<p class="clip-intro">Export replay highlights as MP4 or animated GIF, sized for social media.</p>
<div class="clip-layout">
<!-- Left: load + settings -->
<div class="clip-settings-col">
<div class="clip-panel">
<div class="panel-header"><span>Load Replay</span></div>
<div class="load-controls">
<label class="btn secondary small" for="clip-file-input">Choose File</label>
<input type="file" id="clip-file-input" accept=".json" style="display:none">
<div class="url-row">
<input type="text" id="clip-url-input" placeholder="Or paste replay URL…" class="url-input">
<button id="clip-load-url-btn" class="btn primary small">Load</button>
</div>
</div>
<div id="clip-load-status" class="clip-status hidden"></div>
</div>
<div class="clip-panel" id="clip-settings-panel" style="display:none">
<div class="panel-header"><span>Format Preset</span></div>
<select id="clip-preset-select" class="clip-select">
${presetOptions}
</select>
<div class="preset-dims" id="preset-dims-label"></div>
</div>
<div class="clip-panel" id="clip-range-panel" style="display:none">
<div class="panel-header"><span>Turn Range</span></div>
<div class="range-grid">
<label>Start Turn</label>
<div class="range-row">
<input type="range" id="clip-start-slider" min="0" max="0" value="0" class="range-slider">
<span id="clip-start-val">0</span>
</div>
<label>End Turn</label>
<div class="range-row">
<input type="range" id="clip-end-slider" min="0" max="0" value="0" class="range-slider">
<span id="clip-end-val">0</span>
</div>
<label>FPS</label>
<select id="clip-fps-select" class="clip-select-sm">
<option value="10">10 fps</option>
<option value="15" selected>15 fps</option>
<option value="24">24 fps</option>
<option value="30">30 fps</option>
</select>
</div>
</div>
<div class="clip-panel" id="clip-export-panel" style="display:none">
<div class="panel-header"><span>Export</span></div>
<div class="export-buttons">
<button id="clip-export-mp4" class="btn primary">Export MP4 / WebM</button>
<button id="clip-export-gif" class="btn secondary">Export GIF</button>
</div>
<div id="clip-export-progress" class="clip-progress hidden">
<div class="progress-bar"><div id="clip-progress-fill" class="progress-fill" style="width:0%"></div></div>
<span id="clip-progress-label">0%</span>
</div>
</div>
</div>
<!-- Right: preview -->
<div class="clip-preview-col">
<div class="clip-panel" id="clip-preview-panel" style="display:none">
<div class="panel-header">
<span>Preview</span>
<span id="clip-preview-info" class="preview-info"></span>
</div>
<div id="clip-preview-frame" class="preview-frame"></div>
<div class="preview-nav">
<button id="clip-prev-btn" class="btn small">Prev</button>
<span id="clip-frame-label" class="frame-label">Turn 0</span>
<button id="clip-next-btn" class="btn small">Next</button>
</div>
</div>
</div>
</div>
</div>
${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<void> {
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<void>(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<void> {
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<string, number>();
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<void> {
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 = `
<style>
.clip-intro { color: var(--text-muted); margin-bottom: 24px; }
.clip-layout { display: flex; gap: 20px; align-items: flex-start; }
.clip-settings-col { width: 320px; flex-shrink: 0; display: flex; flex-direction: column; gap: 16px; }
.clip-preview-col { flex: 1; min-width: 0; }
.clip-panel { background: var(--bg-secondary); border-radius: 8px; padding: 16px; }
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-weight: 600; color: var(--text-primary); }
.preview-info { font-size: 0.75rem; color: var(--text-muted); font-weight: 400; }
.load-controls { display: flex; flex-direction: column; gap: 10px; }
.url-row { display: flex; gap: 8px; }
.url-input { flex: 1; background: var(--bg-primary); border: 1px solid var(--border); color: var(--text-primary); padding: 7px 10px; border-radius: 6px; font-size: 0.875rem; }
.clip-status { font-size: 0.8rem; padding: 8px; border-radius: 4px; margin-top: 8px; }
.clip-status.hidden { display: none; }
.clip-status.ok { background: rgba(34,197,94,0.15); color: var(--success); }
.clip-status.error { background: rgba(239,68,68,0.15); color: var(--error); }
.clip-select, .clip-select-sm { width: 100%; background: var(--bg-primary); border: 1px solid var(--border); color: var(--text-primary); padding: 8px; border-radius: 6px; font-size: 0.875rem; margin-bottom: 8px; }
.clip-select-sm { width: auto; }
.preset-dims { font-size: 0.75rem; color: var(--text-muted); }
.range-grid { display: grid; grid-template-columns: 1fr 2fr; gap: 8px 12px; align-items: center; font-size: 0.875rem; color: var(--text-muted); }
.range-row { display: flex; gap: 8px; align-items: center; }
.range-slider { flex: 1; }
.export-buttons { display: flex; gap: 10px; margin-bottom: 12px; }
.export-buttons .btn { flex: 1; }
.clip-progress.hidden { display: none; }
.progress-bar { height: 8px; background: var(--bg-tertiary); border-radius: 4px; overflow: hidden; margin-bottom: 4px; }
.progress-fill { height: 100%; background: var(--accent); border-radius: 4px; transition: width 0.1s; }
.preview-frame { display: flex; justify-content: center; align-items: center; min-height: 200px; background: var(--bg-primary); border-radius: 6px; padding: 8px; overflow: auto; }
.preview-frame canvas { display: block; max-width: 100%; }
.preview-nav { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 12px; }
.frame-label { color: var(--text-muted); font-size: 0.875rem; min-width: 80px; text-align: center; }
@media (max-width: 768px) {
.clip-layout { flex-direction: column; }
.clip-settings-col { width: 100%; }
}
</style>
`;

592
web/src/pages/evolution.ts Normal file
View file

@ -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<string, string> = {
alpha: '#ef4444', // red - core-rushing
beta: '#f59e0b', // amber - energy-focused
gamma: '#22c55e', // green - defensive
delta: '#a78bfa', // violet - experimental
};
const ISLAND_LABELS: Record<string, string> = {
alpha: 'Alpha (Rush)',
beta: 'Beta (Economy)',
gamma: 'Gamma (Defense)',
delta: 'Delta (Experimental)',
};
export async function renderEvolutionPage(): Promise<void> {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="evolution-page">
<h1 class="page-title">Evolution Dashboard</h1>
<div id="evolution-content" class="loading">Loading evolution data...</div>
</div>
`;
const content = document.getElementById('evolution-content');
if (!content) return;
try {
const data = await fetchEvolutionData();
renderDashboard(content, data);
} catch {
content.innerHTML = `
<div class="error">
<p>Evolution data not available yet.</p>
<p class="hint">The evolution pipeline needs to run at least one cycle before data appears here.
Run <code>acb-evolver live-export</code> to generate the data file.</p>
</div>
`;
}
}
function renderDashboard(container: HTMLElement, data: EvolutionLiveData): void {
container.innerHTML = `
<p class="updated-at">Last updated: ${formatTimestamp(data.updated_at)} &nbsp;·&nbsp;
${data.total_programs} programs &nbsp;·&nbsp; ${data.promoted_count} promoted</p>
<section class="evo-section">
<h2 class="evo-section-title">Island Status</h2>
<div class="island-grid" id="island-grid"></div>
</section>
<section class="evo-section">
<h2 class="evo-section-title">Meta Tracker <span class="evo-subtitle">Best fitness per island over generations</span></h2>
<div class="chart-container" id="meta-chart"></div>
</section>
<section class="evo-section">
<h2 class="evo-section-title">Lineage Tree <span class="evo-subtitle">Program ancestry (top 80 by fitness)</span></h2>
<div class="lineage-container" id="lineage-tree"></div>
</section>
<section class="evo-section">
<h2 class="evo-section-title">Generation Log</h2>
<div id="generation-log"></div>
</section>
<style>
.evo-section {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
}
.evo-section-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 16px;
}
.evo-subtitle {
font-size: 0.75rem;
font-weight: 400;
color: var(--text-muted);
text-transform: none;
letter-spacing: 0;
margin-left: 8px;
}
/* Island status grid */
.island-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.island-card {
background-color: var(--bg-primary);
border-radius: 8px;
padding: 16px;
border-left: 4px solid transparent;
}
.island-card-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
}
.island-stat-row {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
font-size: 0.8125rem;
}
.island-stat-label {
color: var(--text-muted);
}
.island-stat-value {
color: var(--text-primary);
font-weight: 500;
}
.island-diversity-bar {
height: 4px;
background-color: var(--bg-tertiary);
border-radius: 2px;
margin-top: 10px;
overflow: hidden;
}
.island-diversity-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s;
}
/* Chart */
.chart-container {
overflow-x: auto;
}
.meta-chart-svg {
display: block;
min-width: 500px;
}
.chart-empty {
color: var(--text-muted);
padding: 20px 0;
font-size: 0.875rem;
}
/* Lineage tree */
.lineage-container {
overflow: auto;
max-height: 480px;
cursor: grab;
}
.lineage-svg {
display: block;
}
/* Generation log table */
.gen-log-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.gen-log-table th,
.gen-log-table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--bg-tertiary);
}
.gen-log-table th {
background-color: var(--bg-tertiary);
color: var(--text-muted);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.gen-log-table tr:last-child td {
border-bottom: none;
}
.gen-log-table tr:hover td {
background-color: var(--bg-tertiary);
}
.island-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.fitness-bar-cell {
display: flex;
align-items: center;
gap: 8px;
}
.fitness-bar-bg {
flex: 1;
height: 6px;
background-color: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
min-width: 60px;
}
.fitness-bar-fill {
height: 100%;
border-radius: 3px;
}
@media (max-width: 700px) {
.island-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 480px) {
.island-grid {
grid-template-columns: 1fr;
}
}
</style>
`;
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<string, IslandStat>): 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 `
<div class="island-card" style="border-left-color: ${color}">
<div class="island-card-name" style="color: ${color}">${escapeHtml(label)}</div>
<div class="island-stat-row">
<span class="island-stat-label">Population</span>
<span class="island-stat-value">${stat.count}</span>
</div>
<div class="island-stat-row">
<span class="island-stat-label">Best Fitness</span>
<span class="island-stat-value">${(stat.best_fitness * 100).toFixed(1)}%</span>
</div>
<div class="island-stat-row">
<span class="island-stat-label">Avg Fitness</span>
<span class="island-stat-value">${(stat.avg_fitness * 100).toFixed(1)}%</span>
</div>
<div class="island-stat-row">
<span class="island-stat-label">Promoted</span>
<span class="island-stat-value">${stat.promoted_count}</span>
</div>
<div class="island-diversity-bar" title="Language diversity ${diversityPct}%">
<div class="island-diversity-fill" style="width: ${diversityPct}%; background-color: ${color}"></div>
</div>
<div style="font-size: 0.7rem; color: var(--text-muted); margin-top: 4px;">
Diversity: ${diversityPct}%
</div>
</div>
`;
});
container.innerHTML = cards.join('');
}
// ── Meta Tracker Chart ─────────────────────────────────────────────────────────
function renderMetaChart(container: HTMLElement, snapshots: MetaSnapshot[]): void {
if (!snapshots || snapshots.length === 0) {
container.innerHTML = '<p class="chart-empty">No generation data yet.</p>';
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(`<circle cx="${points[0].x}" cy="${points[0].y}" r="4" fill="${color}" />`);
}
} else {
const d = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
lineEls.push(`<path d="${d}" fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />`);
for (const p of points) {
dotEls.push(`<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="3" fill="${color}" />`);
}
}
}
// Legend
islands.forEach((island, i) => {
const color = ISLAND_COLORS[island] ?? '#94a3b8';
const lx = padL + i * 120;
const ly = H - 6;
legendEls.push(`
<circle cx="${lx + 6}" cy="${ly - 4}" r="4" fill="${color}" />
<text x="${lx + 14}" y="${ly}" fill="#94a3b8" font-size="11">${escapeHtml(ISLAND_LABELS[island] ?? island)}</text>
`);
});
// 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(`
<line x1="${padL - 4}" y1="${y.toFixed(1)}" x2="${W - padR}" y2="${y.toFixed(1)}"
stroke="#334155" stroke-width="1" />
<text x="${padL - 7}" y="${(y + 4).toFixed(1)}" fill="#94a3b8" font-size="10" text-anchor="end">${v}</text>
`);
}
// 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(`
<text x="${x.toFixed(1)}" y="${(padT + chartH + 18).toFixed(1)}"
fill="#94a3b8" font-size="10" text-anchor="middle">G${snap.generation}</text>
`);
}
container.innerHTML = `
<svg class="meta-chart-svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">
${yTicks.join('')}
${xTicks.join('')}
${lineEls.join('')}
${dotEls.join('')}
${legendEls.join('')}
</svg>
`;
}
// ── Lineage Tree ───────────────────────────────────────────────────────────────
function renderLineageTree(container: HTMLElement, nodes: LineageNode[]): void {
if (!nodes || nodes.length === 0) {
container.innerHTML = '<p style="color: var(--text-muted); font-size: 0.875rem;">No lineage data yet.</p>';
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<number, LineageNode>(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<number, LineageNode[]>();
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<number, { x: number; y: number }>();
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(`<line x1="${pos.x}" y1="${pos.y}" x2="${ppos.x}" y2="${ppos.y}"
stroke="#475569" stroke-width="1" stroke-dasharray="3,2" />`);
}
}
// 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(`
<circle cx="${pos.x}" cy="${pos.y}" r="${r}"
fill="${color}" stroke="${strokeColor}" stroke-width="${strokeW}"
opacity="0.9">
<title>${escapeHtml(title)}</title>
</circle>
`);
}
// Generation labels on the left
const genLabels = gens.map(gen => {
const gIdx = genIndex.get(gen) ?? 0;
const y = PAD_Y + gIdx * V_GAP;
return `<text x="0" y="${y + 4}" fill="#475569" font-size="10" font-family="monospace">G${gen}</text>`;
});
// 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 `
<circle cx="${lx + 5}" cy="${legendY - 4}" r="5" fill="${color}" />
<text x="${lx + 14}" y="${legendY}" fill="#94a3b8" font-size="10">${island}</text>
`;
});
const legendPromo = `
<circle cx="${PAD_X + 450}" cy="${legendY - 4}" r="7" fill="#94a3b8" stroke="#ffffff" stroke-width="2.5" />
<text x="${PAD_X + 462}" y="${legendY}" fill="#94a3b8" font-size="10">promoted</text>
`;
const fullSvgH = svgH + 20;
container.innerHTML = `
<svg class="lineage-svg" viewBox="0 0 ${svgW} ${fullSvgH}" width="${svgW}" height="${fullSvgH}">
<g transform="translate(36,0)">
${edges.join('')}
${nodeEls.join('')}
</g>
<g transform="translate(0,0)">
${genLabels.join('')}
</g>
<g>
${legendEls.join('')}
${legendPromo}
</g>
</svg>
`;
}
// ── Generation Log Table ───────────────────────────────────────────────────────
function renderGenerationLog(container: HTMLElement, log: GenerationEntry[]): void {
if (!log || log.length === 0) {
container.innerHTML = '<p style="color: var(--text-muted); font-size: 0.875rem;">No generation history yet.</p>';
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 `
<tr>
<td>${e.generation}</td>
<td><span class="island-dot" style="background-color:${color}"></span>${escapeHtml(e.island)}</td>
<td>${e.count}</td>
<td>${e.promoted}</td>
<td>
<div class="fitness-bar-cell">
<span style="min-width:42px; color: var(--text-primary)">${bestPct}%</span>
<div class="fitness-bar-bg">
<div class="fitness-bar-fill" style="width:${barWidth}%; background-color:${color}"></div>
</div>
</div>
</td>
<td>${avgPct}%</td>
<td style="color: var(--text-muted); font-size: 0.75rem;">${formatTimestamp(e.evaluated_at)}</td>
</tr>
`;
});
container.innerHTML = `
<table class="gen-log-table">
<thead>
<tr>
<th>Gen</th>
<th>Island</th>
<th>Programs</th>
<th>Promoted</th>
<th>Best Fitness</th>
<th>Avg Fitness</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
${rows.join('')}
</tbody>
</table>
`;
}
// ── Helpers ────────────────────────────────────────────────────────────────────
function formatTimestamp(iso: string): string {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

507
web/src/pages/feedback.ts Normal file
View file

@ -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<string, string>): void {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = buildHTML();
requestAnimationFrame(() => initFeedback());
}
function buildHTML(): string {
const tagButtons = ANNOTATION_TAGS.map(t =>
`<button class="tag-btn" data-tag="${t.id}" style="--tag-color:${t.color}" title="${escapeHtml(t.desc)}">${escapeHtml(t.label)}</button>`,
).join('');
return `
<div class="feedback-page">
<h1 class="page-title">Community Replay Feedback</h1>
<p class="feedback-intro">
Annotate key moments in replays. High-quality annotations are used to seed the
evolution pipeline with interesting positions.
</p>
<div class="feedback-layout">
<!-- Left: load replay -->
<div class="feedback-left">
<div class="fb-panel">
<div class="panel-header"><span>Load a Replay</span></div>
<div class="load-tabs">
<button class="tab-btn active" data-tab="recent">Recent Matches</button>
<button class="tab-btn" data-tab="file">Upload File</button>
<button class="tab-btn" data-tab="url">By URL</button>
</div>
<!-- Recent matches tab -->
<div id="tab-recent" class="tab-content">
<div id="recent-matches-list" class="recent-list">
<div class="loading">Loading recent matches</div>
</div>
</div>
<!-- File upload tab -->
<div id="tab-file" class="tab-content hidden">
<label class="btn secondary" for="fb-file-input">Choose Replay File (.json)</label>
<input type="file" id="fb-file-input" accept=".json" style="display:none">
</div>
<!-- URL tab -->
<div id="tab-url" class="tab-content hidden">
<div class="url-row">
<input type="text" id="fb-url-input" placeholder="Replay URL…" class="url-input">
<button id="fb-load-url-btn" class="btn primary small">Load</button>
</div>
</div>
<div id="fb-load-status" class="fb-status hidden"></div>
</div>
<!-- Annotation form (hidden until replay loaded) -->
<div class="fb-panel" id="annotation-form-panel" style="display:none">
<div class="panel-header"><span>Annotate Turn <span id="annotate-turn-num"></span></span></div>
<div class="turn-nav">
<button id="ann-prev-btn" class="btn small">Prev</button>
<input type="range" id="ann-turn-slider" min="0" max="0" value="0" class="turn-slider">
<button id="ann-next-btn" class="btn small">Next</button>
</div>
<div class="tag-section">
<label class="form-label">Tag this moment:</label>
<div class="tag-buttons" id="tag-buttons">
${tagButtons}
</div>
</div>
<div class="comment-section">
<label class="form-label" for="ann-comment">Comment (optional)</label>
<textarea id="ann-comment" class="ann-textarea" rows="3" placeholder="Describe what's happening here…" maxlength="280"></textarea>
<span id="ann-comment-len" class="char-count">0 / 280</span>
</div>
<button id="submit-annotation-btn" class="btn primary" disabled>Submit Annotation</button>
<div id="submit-status" class="fb-status hidden"></div>
</div>
<!-- Submitted annotations log -->
<div class="fb-panel" id="annotations-log-panel" style="display:none">
<div class="panel-header"><span>Your Annotations</span></div>
<div id="annotations-log" class="annotations-log"></div>
</div>
</div>
<!-- Right: replay viewer -->
<div class="feedback-right" id="fb-viewer-col" style="display:none">
<div class="fb-panel">
<div class="panel-header">
<span id="fb-replay-title">Replay</span>
<span id="fb-replay-info" class="replay-info"></span>
</div>
<canvas id="fb-canvas" class="fb-canvas"></canvas>
<div class="viewer-controls">
<button id="fb-play-btn" class="btn small">Play</button>
<button id="fb-reset-btn" class="btn small secondary">Reset</button>
<span id="fb-turn-label" class="turn-label">Turn 0 / 0</span>
</div>
<!-- Annotation markers overlaid on replay -->
<div id="fb-annotation-markers" class="annotation-markers"></div>
</div>
</div>
</div>
</div>
${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 = '<div class="empty-state-sm">No matches recorded yet.</div>';
return;
}
listEl.innerHTML = recent.map(m => `
<div class="recent-match-row" data-match-id="${m.id}">
<div class="recent-match-bots">${m.participants.map(p => escapeHtml(p.name)).join(' vs ')}</div>
<div class="recent-match-meta">
<span>${m.turns ?? '?'} turns</span>
<span>${formatDate(m.completed_at)}</span>
</div>
</div>
`).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 = '<div class="empty-state-sm">Could not load match list.</div>';
});
// ── 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<void> {
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 `<div class="ann-marker" style="left:${pct.toFixed(1)}%;background:${color}"
title="Turn ${a.turn}: ${escapeHtml(tagInfo?.label ?? a.tag)}${a.comment ? ' — ' + escapeHtml(a.comment) : ''}"></div>`;
}).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 `
<div class="ann-log-row">
<span class="ann-tag-pill" style="background:${tagInfo?.color ?? '#94a3b8'}22;color:${tagInfo?.color ?? '#94a3b8'}">${escapeHtml(tagInfo?.label ?? a.tag)}</span>
<span class="ann-turn">Turn ${a.turn}</span>
${a.comment ? `<span class="ann-comment-text">${escapeHtml(a.comment)}</span>` : ''}
<span class="ann-match-id">${a.match_id.slice(0, 8)}</span>
</div>
`;
}).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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ─── Styles ───────────────────────────────────────────────────────────────────
const FEEDBACK_STYLES = `
<style>
.feedback-intro { color: var(--text-muted); margin-bottom: 24px; max-width: 700px; }
.feedback-layout { display: flex; gap: 20px; align-items: flex-start; }
.feedback-left { width: 360px; flex-shrink: 0; display: flex; flex-direction: column; gap: 16px; }
.feedback-right { flex: 1; min-width: 0; }
.fb-panel { background: var(--bg-secondary); border-radius: 8px; padding: 16px; }
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-weight: 600; color: var(--text-primary); }
.replay-info { font-size: 0.75rem; color: var(--text-muted); font-weight: 400; }
.load-tabs { display: flex; gap: 4px; margin-bottom: 12px; }
.tab-btn { background: var(--bg-tertiary); border: none; color: var(--text-muted); padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; }
.tab-btn.active { background: var(--accent); color: #fff; }
.tab-content.hidden { display: none; }
.tab-content { padding: 4px 0; }
.url-row { display: flex; gap: 8px; }
.url-input { flex: 1; background: var(--bg-primary); border: 1px solid var(--border); color: var(--text-primary); padding: 7px 10px; border-radius: 6px; font-size: 0.875rem; }
.fb-status { font-size: 0.8rem; padding: 8px; border-radius: 4px; margin-top: 8px; }
.fb-status.hidden { display: none; }
.fb-status.ok { background: rgba(34,197,94,0.15); color: var(--success); }
.fb-status.error { background: rgba(239,68,68,0.15); color: var(--error); }
.recent-list { max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
.recent-match-row { background: var(--bg-primary); border-radius: 6px; padding: 8px 12px; cursor: pointer; transition: background 0.15s; }
.recent-match-row:hover { background: var(--bg-tertiary); }
.recent-match-bots { font-size: 0.875rem; color: var(--text-primary); margin-bottom: 2px; }
.recent-match-meta { display: flex; gap: 12px; font-size: 0.75rem; color: var(--text-muted); }
.empty-state-sm { color: var(--text-muted); font-size: 0.875rem; padding: 12px 0; }
.turn-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 14px; }
.turn-slider { flex: 1; }
.tag-section { margin-bottom: 14px; }
.form-label { display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 6px; }
.tag-buttons { display: flex; flex-wrap: wrap; gap: 6px; }
.tag-btn { background: var(--bg-primary); border: 2px solid var(--tag-color, #475569); color: var(--tag-color, #94a3b8); padding: 5px 10px; border-radius: 20px; cursor: pointer; font-size: 0.8rem; transition: all 0.15s; }
.tag-btn:hover { background: color-mix(in srgb, var(--tag-color, #475569) 15%, transparent); }
.tag-btn.selected { background: color-mix(in srgb, var(--tag-color, #475569) 20%, transparent); font-weight: 600; }
.comment-section { margin-bottom: 14px; }
.ann-textarea { width: 100%; background: var(--bg-primary); border: 1px solid var(--border); color: var(--text-primary); padding: 8px; border-radius: 6px; font-size: 0.875rem; resize: vertical; font-family: inherit; }
.char-count { font-size: 0.7rem; color: var(--text-muted); float: right; }
.fb-canvas { display: block; width: 100%; border-radius: 6px; background: var(--bg-primary); }
.viewer-controls { display: flex; gap: 8px; align-items: center; margin-top: 10px; }
.turn-label { color: var(--text-muted); font-size: 0.875rem; margin-left: auto; }
.annotation-markers { position: relative; height: 16px; background: var(--bg-tertiary); border-radius: 4px; margin-top: 8px; }
.ann-marker { position: absolute; top: 2px; bottom: 2px; width: 4px; transform: translateX(-50%); border-radius: 2px; cursor: pointer; }
.annotations-log { display: flex; flex-direction: column; gap: 6px; max-height: 200px; overflow-y: auto; }
.ann-log-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.8rem; padding: 4px 0; border-bottom: 1px solid var(--bg-tertiary); }
.ann-tag-pill { padding: 2px 8px; border-radius: 10px; font-size: 0.75rem; font-weight: 600; }
.ann-turn { color: var(--text-muted); }
.ann-comment-text { color: var(--text-secondary); flex: 1; }
.ann-match-id { color: var(--text-muted); font-family: monospace; font-size: 0.7rem; margin-left: auto; }
@media (max-width: 900px) {
.feedback-layout { flex-direction: column; }
.feedback-left { width: 100%; }
}
</style>
`;

341
web/src/pages/rivalries.ts Normal file
View file

@ -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<string, string>): Promise<void> {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="rivalries-page">
<h1 class="page-title">Rivalries</h1>
<p class="page-subtitle">Head-to-head storylines from the most contested matchups on the grid.</p>
<div id="rivalries-content" class="loading">Analysing match history</div>
</div>
${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<string, string>();
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 = `
<div class="empty-state">
<p>No rivalries detected yet.</p>
<p class="hint">Rivalries appear when two bots have played at least 3 head-to-head matches.
Check back after more matches have been recorded.</p>
</div>
`;
return;
}
renderRivalryCards(content, rivalries);
} catch (err) {
content.innerHTML = `<div class="error">Failed to load rivalry data: ${err}</div>`;
}
}
// ─── Rivalry detection ────────────────────────────────────────────────────────
function detectRivalries(matches: MatchSummary[], nameMap: Map<string, string>): 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<PairKey, PairRecord>();
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, any>): 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 = `
<div class="rivalry-grid">
${rivalries.map((r, i) => `
<div class="rivalry-card ${i === 0 ? 'featured' : ''}">
${i === 0 ? '<div class="rivalry-badge">Top Rivalry</div>' : ''}
<div class="rivalry-header">
<div class="rivalry-combatant">
<a href="#/bot/${r.bot0Id}" class="combatant-name">${escapeHtml(r.bot0Name)}</a>
<span class="combatant-record">${r.bot0Wins}W</span>
</div>
<div class="rivalry-vs">
<span class="vs-text">VS</span>
<span class="rivalry-total">${r.totalMatches} matches</span>
</div>
<div class="rivalry-combatant right">
<a href="#/bot/${r.bot1Id}" class="combatant-name">${escapeHtml(r.bot1Name)}</a>
<span class="combatant-record">${r.bot1Wins}W</span>
</div>
</div>
<div class="win-bar-container">
${buildWinBar(r)}
</div>
<p class="rivalry-narrative">${escapeHtml(r.narrative)}</p>
<div class="rivalry-footer">
${r.streak ? `<span class="streak-badge">${escapeHtml(r.streak.bot)} on ${r.streak.count}-win streak</span>` : ''}
${r.draws > 0 ? `<span class="draws-tag">${r.draws} draw${r.draws !== 1 ? 's' : ''}</span>` : ''}
<span class="last-match">Last: ${dateStr(r.lastMatchAt)}</span>
<a href="#/matches?bot0=${r.bot0Id}&bot1=${r.bot1Id}" class="btn small secondary">All Matches</a>
</div>
</div>
`).join('')}
</div>
`;
}
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 `
<div class="win-bar">
<div class="win-bar-seg seg0" style="width:${pct0.toFixed(1)}%" title="${r.bot0Name}: ${r.bot0Wins} wins"></div>
<div class="win-bar-seg seg-draw" style="width:${pctD.toFixed(1)}%" title="Draws: ${r.draws}"></div>
<div class="win-bar-seg seg1" style="width:${pct1.toFixed(1)}%" title="${r.bot1Name}: ${r.bot1Wins} wins"></div>
</div>
<div class="win-bar-labels">
<span style="color:#3b82f6">${r.bot0Wins}W (${pct0.toFixed(0)}%)</span>
<span style="color:#94a3b8">${r.draws > 0 ? r.draws + ' draws' : ''}</span>
<span style="color:#ef4444">${pct1.toFixed(0)}% (${r.bot1Wins}W)</span>
</div>
`;
}
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ─── Styles ───────────────────────────────────────────────────────────────────
const RIVALRY_STYLES = `
<style>
.rivalries-page .page-subtitle { color: var(--text-muted); margin-bottom: 24px; }
.rivalry-grid { display: flex; flex-direction: column; gap: 16px; }
.rivalry-card { background: var(--bg-secondary); border-radius: 10px; padding: 20px; position: relative; border: 1px solid var(--border); }
.rivalry-card.featured { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.rivalry-badge { position: absolute; top: -1px; right: 20px; background: var(--accent); color: #fff; font-size: 0.7rem; font-weight: 700; padding: 3px 10px; border-radius: 0 0 6px 6px; text-transform: uppercase; letter-spacing: 0.05em; }
.rivalry-header { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 16px; margin-bottom: 12px; }
.rivalry-combatant { display: flex; flex-direction: column; gap: 4px; }
.rivalry-combatant.right { align-items: flex-end; text-align: right; }
.combatant-name { color: var(--text-primary); text-decoration: none; font-size: 1.1rem; font-weight: 600; }
.combatant-name:hover { color: var(--accent); }
.combatant-record { font-size: 0.875rem; color: var(--text-muted); }
.rivalry-vs { text-align: center; }
.vs-text { display: block; font-size: 1.25rem; font-weight: 800; color: var(--text-muted); }
.rivalry-total { font-size: 0.7rem; color: var(--text-muted); }
.win-bar-container { margin: 12px 0; }
.win-bar { height: 10px; border-radius: 5px; overflow: hidden; display: flex; }
.win-bar-seg { height: 100%; }
.seg0 { background: #3b82f6; }
.seg-draw { background: #475569; }
.seg1 { background: #ef4444; }
.win-bar-labels { display: flex; justify-content: space-between; font-size: 0.7rem; margin-top: 4px; }
.rivalry-narrative { color: var(--text-muted); font-style: italic; font-size: 0.875rem; margin: 12px 0; line-height: 1.5; }
.rivalry-footer { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
.streak-badge { background: rgba(245,158,11,0.15); color: var(--warning); font-size: 0.75rem; padding: 3px 8px; border-radius: 12px; }
.draws-tag { background: var(--bg-tertiary); color: var(--text-muted); font-size: 0.75rem; padding: 3px 8px; border-radius: 12px; }
.last-match { color: var(--text-muted); font-size: 0.75rem; margin-left: auto; }
.empty-state { background: var(--bg-secondary); border-radius: 8px; padding: 40px; text-align: center; color: var(--text-muted); }
.empty-state .hint { margin-top: 10px; font-size: 0.875rem; }
</style>
`;

543
web/src/pages/sandbox.ts Normal file
View file

@ -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<string, string>): 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 `
<div class="sandbox-page">
<h1 class="page-title">Bot Sandbox</h1>
<p class="sandbox-intro">Write JavaScript bot logic, pick an opponent, and run an in-browser match instantly no server required.</p>
<div class="sandbox-layout">
<!-- Left: editor -->
<div class="sandbox-editor-col">
<div class="sandbox-panel">
<div class="panel-header">
<span>Bot Code</span>
<div class="panel-actions">
<button id="wasm-upload-btn" class="btn small secondary" title="Upload a compiled .wasm bot">Upload WASM</button>
<input type="file" id="wasm-file-input" accept=".wasm" style="display:none">
<button id="reset-code-btn" class="btn small secondary">Reset</button>
</div>
</div>
<div id="monaco-container" style="height:420px;border-radius:6px;overflow:hidden;"></div>
<div id="wasm-status" class="wasm-status hidden"></div>
</div>
<div class="sandbox-panel">
<div class="panel-header"><span>WASM Bot Interface Spec</span>
<button id="toggle-spec-btn" class="btn small secondary">Show</button>
</div>
<pre id="wasm-spec" class="code-block hidden">${escapeHtml(WASM_BOT_SPEC)}</pre>
</div>
</div>
<!-- Right: config + run + viewer -->
<div class="sandbox-controls-col">
<div class="sandbox-panel">
<div class="panel-header"><span>Match Settings</span></div>
<div class="settings-grid">
<label>Opponent Strategy</label>
<select id="opponent-select">
<option value="random">Random</option>
<option value="gatherer" selected>Gatherer</option>
<option value="rusher">Rusher</option>
<option value="guardian">Guardian</option>
<option value="swarm">Swarm</option>
<option value="hunter">Hunter</option>
</select>
<label>Grid Size</label>
<select id="grid-size-select">
<option value="20">Small (20×20)</option>
<option value="30" selected>Medium (30×30)</option>
<option value="40">Large (40×40)</option>
</select>
<label>Max Turns</label>
<select id="max-turns-select">
<option value="100">100</option>
<option value="200" selected>200</option>
<option value="300">300</option>
</select>
<label>Playback Speed</label>
<input type="range" id="speed-slider" min="20" max="500" value="100" class="speed-slider">
<span id="speed-label" class="speed-label">100ms</span>
</div>
<button id="run-btn" class="btn primary run-btn">Run Match</button>
</div>
<div class="sandbox-panel" id="result-panel" style="display:none">
<div class="panel-header"><span>Match Result</span></div>
<div id="match-result" class="match-result"></div>
</div>
<div class="sandbox-panel" id="performance-panel" style="display:none">
<div class="panel-header"><span>Performance Stats</span></div>
<div id="perf-stats" class="perf-stats"></div>
</div>
</div>
</div>
<!-- Replay viewer below -->
<div id="replay-section" class="replay-section" style="display:none">
<h2 class="section-title">Replay</h2>
<div class="replay-layout-sandbox">
<div class="canvas-wrapper">
<canvas id="sandbox-canvas"></canvas>
</div>
<div class="sandbox-replay-controls">
<div class="playback-controls">
<button id="sb-play-btn" class="btn">Play</button>
<button id="sb-prev-btn" class="btn">Prev</button>
<button id="sb-next-btn" class="btn">Next</button>
<button id="sb-reset-btn" class="btn">Reset</button>
<button id="download-replay-btn" class="btn secondary">Download Replay</button>
</div>
<div class="slider-group">
<label>Turn: <span id="sb-turn-display">0</span> / <span id="sb-total-turns">0</span></label>
<input type="range" id="sb-turn-slider" min="0" max="0" value="0">
</div>
<div id="sb-events" class="event-log"></div>
</div>
</div>
</div>
</div>
${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 = `<textarea id="code-textarea" style="width:100%;height:100%;background:#1e1e1e;color:#d4d4d4;font-family:monospace;font-size:13px;border:none;padding:10px;resize:none;">${escapeHtml(STARTER_CODE)}</textarea>`;
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 = `
<div class="perf-row"><span>Match duration (JS)</span><span>${elapsed.toFixed(1)} ms</span></div>
<div class="perf-row"><span>Turns played</span><span>${result.turns}</span></div>
<div class="perf-row"><span>Your bots alive</span><span>${result.bots_alive[0]}</span></div>
<div class="perf-row"><span>Opponent bots alive</span><span>${result.bots_alive[1]}</span></div>
`;
// 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
? '<div class="no-events">No events</div>'
: events.map(ev => `<div class="event"><span style="color:#fbbf24">${ev.type.replace(/_/g,' ')}</span></div>`).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<BotStrategy> {
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 <script src="/wasm/wasm_exec.js"> to the page.');
}
}
if (!acbBotExport?.compute_moves) {
throw new Error('WASM module does not export acbBot.compute_moves');
}
return (state: VisibleState): Move[] => {
try {
const result = acbBotExport!.compute_moves(JSON.stringify(state));
return JSON.parse(result) as Move[];
} catch {
return [];
}
};
}
// ────────────────────────────────────────────────────────────────────────────
// Monaco loader (CDN)
// ────────────────────────────────────────────────────────────────────────────
function loadMonaco(): Promise<any> {
return new Promise((resolve, reject) => {
if ((globalThis as any).monaco) { resolve((globalThis as any).monaco); return; }
// Load AMD loader then monaco
const loaderScript = document.createElement('script');
loaderScript.src = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js';
loaderScript.onload = () => {
(globalThis as any).require.config({
paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' },
});
(globalThis as any).require(['vs/editor/editor.main'], (monaco: any) => {
resolve(monaco);
});
};
loaderScript.onerror = reject;
document.head.appendChild(loaderScript);
});
}
// ────────────────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────────────────
function formatResult(result: any, replay: any): string {
const p0Name = replay.players[0]?.name ?? 'Your Bot';
const p1Name = replay.players[1]?.name ?? 'Opponent';
const winnerName = result.winner === 0 ? p0Name : result.winner === 1 ? p1Name : 'Draw';
const winnerClass = result.winner === 0 ? 'win' : result.winner === 1 ? 'loss' : 'draw';
return `
<div class="result-banner ${winnerClass}">
<strong>${result.winner >= 0 ? winnerName + ' wins!' : 'Draw'}</strong>
<span>${result.reason}</span>
</div>
<div class="result-stats">
<div>${p0Name}: ${result.scores[0]} pts, ${result.bots_alive[0]} bots alive</div>
<div>${p1Name}: ${result.scores[1]} pts, ${result.bots_alive[1]} bots alive</div>
</div>
`;
}
function escapeHtml(s: string): string {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ────────────────────────────────────────────────────────────────────────────
// Styles
// ────────────────────────────────────────────────────────────────────────────
const SANDBOX_STYLES = `
<style>
.sandbox-intro { color: var(--text-muted); margin-bottom: 24px; }
.sandbox-layout { display: flex; gap: 20px; }
.sandbox-editor-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 16px; }
.sandbox-controls-col { width: 300px; flex-shrink: 0; display: flex; flex-direction: column; gap: 16px; }
.sandbox-panel { background: var(--bg-secondary); border-radius: 8px; padding: 16px; }
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-weight: 600; color: var(--text-primary); }
.panel-actions { display: flex; gap: 8px; }
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; align-items: center; font-size: 0.875rem; color: var(--text-muted); margin-bottom: 16px; }
.settings-grid select, .settings-grid input { background: var(--bg-primary); border: 1px solid var(--border); color: var(--text-primary); padding: 6px; border-radius: 4px; font-size: 0.875rem; }
.speed-slider { width: 100%; }
.speed-label { color: var(--text-muted); font-size: 0.75rem; }
.run-btn { width: 100%; padding: 12px; font-size: 1rem; }
.wasm-status { font-size: 0.8rem; padding: 8px; border-radius: 4px; margin-top: 8px; }
.wasm-status.hidden { display: none; }
.wasm-status.ok { background: rgba(34,197,94,0.15); color: var(--success); }
.wasm-status.error { background: rgba(239,68,68,0.15); color: var(--error); }
.code-block { background: var(--bg-primary); padding: 12px; border-radius: 6px; font-size: 0.75rem; font-family: monospace; white-space: pre; overflow-x: auto; color: var(--text-muted); }
.code-block.hidden { display: none; }
.match-result .result-banner { padding: 12px 16px; border-radius: 6px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; }
.result-banner.win { background: rgba(34,197,94,0.15); color: var(--success); }
.result-banner.loss { background: rgba(239,68,68,0.15); color: var(--error); }
.result-banner.draw { background: rgba(245,158,11,0.15); color: var(--warning); }
.result-stats { font-size: 0.875rem; color: var(--text-muted); display: flex; flex-direction: column; gap: 4px; }
.perf-stats .perf-row { display: flex; justify-content: space-between; font-size: 0.875rem; color: var(--text-muted); padding: 4px 0; border-bottom: 1px solid var(--bg-tertiary); }
.section-title { font-size: 1.25rem; color: var(--text-primary); margin: 24px 0 16px; }
.replay-section { margin-top: 8px; }
.replay-layout-sandbox { display: flex; gap: 20px; }
.canvas-wrapper { background: var(--bg-secondary); border-radius: 8px; padding: 10px; overflow: auto; flex: 1; }
.sandbox-replay-controls { width: 260px; flex-shrink: 0; background: var(--bg-secondary); border-radius: 8px; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.playback-controls { display: flex; flex-wrap: wrap; gap: 6px; }
.slider-group label { display: block; color: var(--text-muted); font-size: 0.875rem; margin-bottom: 4px; }
.slider-group input[type=range] { width: 100%; }
.event-log { max-height: 150px; overflow-y: auto; font-size: 0.75rem; font-family: monospace; }
.event-log .event { padding: 3px 0; border-bottom: 1px solid var(--bg-tertiary); }
.no-events { color: var(--text-muted); }
@media (max-width: 900px) {
.sandbox-layout { flex-direction: column; }
.sandbox-controls-col { width: 100%; }
.replay-layout-sandbox { flex-direction: column; }
.sandbox-replay-controls { width: 100%; }
}
</style>
`;

View file

@ -72,6 +72,7 @@ export interface ReplayTurn {
}
export interface Replay {
format_version?: string; // semver, e.g. "1.0" — absent in pre-v1 replays
match_id: string;
config: Config;
start_time: string;

282
web/src/win-probability.ts Normal file
View file

@ -0,0 +1,282 @@
// Win probability via Monte Carlo rollout from a replay snapshot.
//
// Usage:
// const wp = new WinProbabilityEngine(replay);
// const probs = await wp.computeAll(50); // run 50 simulations per turn
// const sparkline = wp.getSparkline(); // [{turn, p0, p1}]
// const critical = wp.getCriticalMoments();
import {
type Config, type Replay, type ReplayTurn,
type Move, type GameState, type Bot, type Core, type EnergyNode,
type Player, type MatchResult,
randomStrategy,
getVisibleState, executeTurn, posKey,
} from './engine';
export interface WinProbPoint {
turn: number;
p0WinProb: number;
p1WinProb: number;
drawProb: number;
}
export interface CriticalMoment {
turn: number;
description: string;
deltaP0: number; // change in p0 win probability (positive = improved for p0)
type: 'swing' | 'kill' | 'capture' | 'energy' | 'milestone';
}
export class WinProbabilityEngine {
private replay: Replay;
private points: WinProbPoint[] = [];
constructor(replay: Replay) {
this.replay = replay;
}
/**
* Compute win probability at every Nth turn using Monte Carlo rollouts.
* @param simulations number of random playouts per sampled turn
* @param step sample every N turns (default 5)
*/
async computeAll(simulations = 50, step = 5): Promise<WinProbPoint[]> {
const turns = this.replay.turns;
this.points = [];
const cfg = this.replay.config;
const maxTurn = turns.length - 1;
for (let t = 0; t <= maxTurn; t += step) {
// Allow the browser to stay responsive
if (t % (step * 10) === 0) {
await yieldToUI();
}
const prob = this.rollout(turns[t], cfg, simulations);
this.points.push({ turn: t, ...prob });
}
// Always include the last turn
if (this.points[this.points.length - 1]?.turn !== maxTurn) {
const prob = this.rollout(turns[maxTurn], cfg, simulations);
this.points.push({ turn: maxTurn, ...prob });
}
return this.points;
}
getSparkline(): WinProbPoint[] {
return this.points;
}
/**
* Returns turns where the win probability swung by >= 15 percentage points.
*/
getCriticalMoments(): CriticalMoment[] {
if (this.points.length < 2) return [];
const moments: CriticalMoment[] = [];
for (let i = 1; i < this.points.length; i++) {
const prev = this.points[i - 1];
const curr = this.points[i];
const delta = curr.p0WinProb - prev.p0WinProb;
if (Math.abs(delta) >= 0.15) {
const turnData = this.replay.turns[curr.turn];
const description = this.describeMoment(turnData, delta);
moments.push({
turn: curr.turn,
description,
deltaP0: delta,
type: classifyMoment(turnData, delta),
});
}
}
return moments;
}
// ── Private helpers ──────────────────────────────────────────────────────
private rollout(
snapshot: ReplayTurn,
cfg: Config,
simulations: number,
): { p0WinProb: number; p1WinProb: number; drawProb: number } {
let p0Wins = 0, p1Wins = 0, draws = 0;
for (let i = 0; i < simulations; i++) {
const winner = simulateFromSnapshot(snapshot, cfg, this.replay);
if (winner === 0) p0Wins++;
else if (winner === 1) p1Wins++;
else draws++;
}
return {
p0WinProb: p0Wins / simulations,
p1WinProb: p1Wins / simulations,
drawProb: draws / simulations,
};
}
private describeMoment(turn: ReplayTurn | undefined, delta: number): string {
if (!turn) return 'Probability shift';
const events = turn.events ?? [];
const kills = events.filter(e => e.type === 'bot_died').length;
const captures = events.filter(e => e.type === 'core_captured').length;
if (captures > 0) return `Core captured (${delta > 0 ? '+' : ''}${(delta * 100).toFixed(0)}% shift)`;
if (kills > 2) return `${kills} bots eliminated (${delta > 0 ? '+' : ''}${(delta * 100).toFixed(0)}% shift)`;
return `Probability swung ${delta > 0 ? '+' : ''}${(delta * 100).toFixed(0)}%`;
}
}
// ────────────────────────────────────────────────────────────────────────────
// Single-game rollout from a snapshot
// ────────────────────────────────────────────────────────────────────────────
function simulateFromSnapshot(snapshot: ReplayTurn, cfg: Config, replay: Replay): number {
// Reconstruct a lightweight game state from the snapshot
const gs = replaySnapshotToGameState(snapshot, cfg, replay);
// Run the rest of the match with random strategies
const s0 = randomStrategy;
const s1 = randomStrategy;
let result: MatchResult | null = null;
let safety = cfg.max_turns - snapshot.turn + 1;
while (!result && safety-- > 0) {
const allMoves = new Map<number, Move[]>();
for (const p of gs.players) {
const visible = getVisibleState(gs, p.id);
try {
allMoves.set(p.id, p.id === 0 ? s0(visible) : s1(visible));
} catch {
allMoves.set(p.id, []);
}
}
result = executeTurn(gs, allMoves);
}
return result?.winner ?? -1;
}
function replaySnapshotToGameState(snap: ReplayTurn, cfg: Config, replay: Replay): GameState {
const walls = new Set<string>(
(replay.map?.walls ?? []).map((p: any) => posKey(p)),
);
const bots: Bot[] = (snap.bots ?? []).map(b => ({ ...b }));
const cores: Core[] = (snap.cores ?? []).map(c => ({ ...c }));
// Reconstruct energy nodes from map + current state
const energyOnTile = new Set<string>((snap.energy ?? []).map(posKey));
const energy: EnergyNode[] = (replay.map?.energy_nodes ?? []).map((p: any) => ({
position: p,
hasEnergy: energyOnTile.has(posKey(p)),
tick: 0,
}));
const players: Player[] = (snap.scores ?? [0, 0]).map((score: number, id: number) => ({
id,
energy: snap.energy_held?.[id] ?? 0,
score,
botCount: bots.filter(b => b.alive && b.owner === id).length,
}));
return {
config: cfg,
bots,
cores,
energy,
players,
turn: snap.turn,
matchId: replay.match_id,
walls,
events: [],
dominance: new Map(),
};
}
function classifyMoment(turn: ReplayTurn | undefined, _delta: number): CriticalMoment['type'] {
if (!turn) return 'swing';
const events = turn.events ?? [];
if (events.some((e: any) => e.type === 'core_captured')) return 'capture';
if (events.filter((e: any) => e.type === 'bot_died').length > 2) return 'kill';
if (events.some((e: any) => e.type === 'energy_collected')) return 'energy';
return 'swing';
}
function yieldToUI(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0));
}
// ────────────────────────────────────────────────────────────────────────────
// SVG sparkline renderer
// ────────────────────────────────────────────────────────────────────────────
export function renderWinProbSparkline(
container: HTMLElement,
points: WinProbPoint[],
options: { width?: number; height?: number; showLegend?: boolean } = {},
): void {
const W = (options.width ?? container.clientWidth) || 400;
const H = options.height ?? 80;
const showLegend = options.showLegend ?? true;
if (points.length < 2) {
container.innerHTML = '<div style="color:var(--text-muted);font-size:0.75rem;text-align:center;padding:10px">Not enough data</div>';
return;
}
const maxTurn = points[points.length - 1].turn;
function x(turn: number): number { return (turn / maxTurn) * W; }
function y(prob: number): number { return H - prob * H; }
// Build SVG paths
const p0Path = points.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${x(pt.turn).toFixed(1)} ${y(pt.p0WinProb).toFixed(1)}`).join(' ');
const p1Path = points.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${x(pt.turn).toFixed(1)} ${y(pt.p1WinProb).toFixed(1)}`).join(' ');
// 50% line
const midY = y(0.5).toFixed(1);
const svg = `
<svg width="${W}" height="${H + (showLegend ? 20 : 0)}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="p0grad" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.3"/>
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="${W}" height="${H}" fill="transparent"/>
<!-- 50% line -->
<line x1="0" y1="${midY}" x2="${W}" y2="${midY}" stroke="#475569" stroke-width="1" stroke-dasharray="4,4"/>
<!-- P0 fill -->
<path d="${p0Path} L ${W} ${H} L 0 ${H} Z" fill="url(#p0grad)"/>
<!-- P0 line -->
<path d="${p0Path}" fill="none" stroke="#3b82f6" stroke-width="2"/>
<!-- P1 line -->
<path d="${p1Path}" fill="none" stroke="#ef4444" stroke-width="2"/>
${showLegend ? `
<circle cx="12" cy="${H + 12}" r="5" fill="#3b82f6"/>
<text x="22" y="${H + 16}" fill="#94a3b8" font-size="11">Player 0</text>
<circle cx="90" cy="${H + 12}" r="5" fill="#ef4444"/>
<text x="100" y="${H + 16}" fill="#94a3b8" font-size="11">Player 1</text>
` : ''}
</svg>
`;
container.innerHTML = svg;
}
// ────────────────────────────────────────────────────────────────────────────
// Exported re-type for Replay (mirrors types.ts shape)
// ────────────────────────────────────────────────────────────────────────────
// Re-export the Replay type from engine for consumers that only import
// from win-probability
export type { Replay } from './engine';