ai-code-battle/cmd/acb-api/predictions.go
jedarden f5d7553f98 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>
2026-03-29 01:13:23 -04:00

236 lines
6.5 KiB
Go

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