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>
236 lines
6.5 KiB
Go
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,
|
|
})
|
|
}
|