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:
parent
875ccdbe83
commit
f5d7553f98
36 changed files with 6903 additions and 382 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -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
236
cmd/acb-api/predictions.go
Normal 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
248
cmd/acb-api/seasons.go
Normal 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
279
cmd/acb-api/series.go
Normal 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,
|
||||
})
|
||||
}
|
||||
267
cmd/acb-evolver/internal/live/exporter.go
Normal file
267
cmd/acb-evolver/internal/live/exporter.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
299
cmd/acb-indexer/src/narrative.ts
Normal file
299
cmd/acb-indexer/src/narrative.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
cmd/acb-wasm/botmain/gatherer/main.go
Normal file
47
cmd/acb-wasm/botmain/gatherer/main.go
Normal 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
|
||||
}
|
||||
47
cmd/acb-wasm/botmain/guardian/main.go
Normal file
47
cmd/acb-wasm/botmain/guardian/main.go
Normal 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
|
||||
}
|
||||
47
cmd/acb-wasm/botmain/hunter/main.go
Normal file
47
cmd/acb-wasm/botmain/hunter/main.go
Normal 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
|
||||
}
|
||||
52
cmd/acb-wasm/botmain/random/main.go
Normal file
52
cmd/acb-wasm/botmain/random/main.go
Normal 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
|
||||
}
|
||||
47
cmd/acb-wasm/botmain/rusher/main.go
Normal file
47
cmd/acb-wasm/botmain/rusher/main.go
Normal 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
|
||||
}
|
||||
47
cmd/acb-wasm/botmain/swarm/main.go
Normal file
47
cmd/acb-wasm/botmain/swarm/main.go
Normal 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
15
cmd/acb-wasm/bots.go
Normal 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
46
cmd/acb-wasm/build.sh
Executable 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
210
cmd/acb-wasm/main.go
Normal 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
|
||||
}
|
||||
301
cmd/acb-wasm/strategies/strategies.go
Normal file
301
cmd/acb-wasm/strategies/strategies.go
Normal 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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
283
web/src/commentary.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Template banks
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const INTROS = [
|
||||
"Welcome to this clash between {p0} and {p1} on a {turns}-turn battlefield. May the best algorithm win!",
|
||||
"It's {p0} vs {p1} in what promises to be a tactical showdown. {turns} turns stand between them and glory.",
|
||||
"Two bots enter, one leaves victorious. {p0} and {p1} face off in a contest of strategy and speed.",
|
||||
"The grid is set, the bots are ready. {p0} against {p1} — {turns} turns to prove dominance.",
|
||||
"In the arena of silicon and logic, {p0} squares up against {p1}. Let the match begin!",
|
||||
];
|
||||
|
||||
const MASS_KILL_TEMPLATES = [
|
||||
"Carnage on the grid! {count} bots fall in rapid succession — neither side escapes unscathed.",
|
||||
"A fierce skirmish erupts, leaving {count} units destroyed in a matter of moments.",
|
||||
"The battlefield runs hot as {count} bots are eliminated in a single dramatic turn.",
|
||||
"Chaos reigns! {count} bots are lost in a collision of forces.",
|
||||
];
|
||||
|
||||
const CORE_CAPTURE_TEMPLATES = [
|
||||
"{capturer} strikes deep into enemy territory, razing {victim}'s core! The tactical situation shifts dramatically.",
|
||||
"A bold offensive play by {capturer} — {victim}'s core falls! This could be the turning point.",
|
||||
"{victim}'s core is captured by {capturer}'s forces. The tide of war is turning.",
|
||||
"Critical blow! {capturer} eliminates {victim}'s core, threatening to end this match early.",
|
||||
];
|
||||
|
||||
const SPAWN_TEMPLATES = [
|
||||
"{player} is rapidly expanding its forces. Numbers could be decisive here.",
|
||||
"Steady energy collection allows {player} to keep the bot production line running.",
|
||||
"{player}'s economy is humming — fresh units pour onto the battlefield.",
|
||||
];
|
||||
|
||||
const PROB_SWING_TEMPLATES = [
|
||||
"The models give {leading} a {prob}% win probability now — {trailing} needs to respond quickly.",
|
||||
"Statistical edge shifting toward {leading} ({prob}%). {trailing} is under pressure.",
|
||||
"{leading} has established clear momentum, pushing win probability to {prob}%.",
|
||||
"A {prob}% win probability for {leading} — but this grid has seen bigger comebacks.",
|
||||
];
|
||||
|
||||
const QUARTER_TEMPLATES = [
|
||||
"Quarter-point check: {p0} has {p0Bots} bots, {p1} has {p1Bots}. {p0Bots > p1Bots ? p0 + ' holds the numerical edge' : p1 + ' has the numbers advantage'}.",
|
||||
"25 turns in: bot counts are {p0}:{p0Bots} vs {p1}:{p1Bots}. The positioning battle is just beginning.",
|
||||
];
|
||||
|
||||
const HALFWAY_TEMPLATES = [
|
||||
"Halfway through! Score: {p0} at {p0Score} vs {p1} at {p1Score}. {p0Score > p1Score ? p0 : p1} leads on energy collected.",
|
||||
"The midpoint of the match sees {p0} scoring {p0Score} to {p1}'s {p1Score}. Still everything to play for.",
|
||||
];
|
||||
|
||||
const WIN_OUTROS = [
|
||||
"{winner} clinches it via {reason}! A commanding performance that leaves no doubt about the result.",
|
||||
"Victory for {winner} by {reason} — {loser} fought hard but couldn't overcome the tactical deficit.",
|
||||
"{winner} takes the match! {reason} sealed the deal in {totalTurns} turns of intense grid warfare.",
|
||||
"What a match! {winner} prevails through {reason}. {loser} will need to reconsider its strategy.",
|
||||
];
|
||||
|
||||
const DRAW_OUTROS = [
|
||||
"The match ends in a draw after {totalTurns} turns! An evenly matched contest that honours both competitors.",
|
||||
"Neither {p0} nor {p1} could claim dominance in {totalTurns} turns — honours even!",
|
||||
"A stalemate after {totalTurns} turns. Both bots showed equal resilience on the grid.",
|
||||
];
|
||||
687
web/src/engine.ts
Normal file
687
web/src/engine.ts
Normal 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
780
web/src/pages/clip-maker.ts
Normal 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
592
web/src/pages/evolution.ts
Normal 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)} ·
|
||||
${data.total_programs} programs · ${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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
507
web/src/pages/feedback.ts
Normal file
507
web/src/pages/feedback.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ─── 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
341
web/src/pages/rivalries.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ─── 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
543
web/src/pages/sandbox.ts
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 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>
|
||||
`;
|
||||
|
|
@ -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
282
web/src/win-probability.ts
Normal 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';
|
||||
Loading…
Add table
Reference in a new issue