diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index f2b5262..af8d7eb 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -da824f736002b1e597d4c5c658c1122a1f3895b4 +4dd91decad4538636d34df90d01199a74a7e1392 diff --git a/cmd/acb-api/db.go b/cmd/acb-api/db.go index f2854a1..15439dc 100644 --- a/cmd/acb-api/db.go +++ b/cmd/acb-api/db.go @@ -165,9 +165,17 @@ CREATE TABLE IF NOT EXISTS bots ( consec_fails INTEGER NOT NULL DEFAULT 0, archetype VARCHAR(64), crash_strikes INTEGER NOT NULL DEFAULT 0, - cooldown_until TIMESTAMPTZ + cooldown_until TIMESTAMPTZ, + debug_public BOOLEAN NOT NULL DEFAULT FALSE ); +-- Add debug_public column if it doesn't exist (idempotent migration) +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'bots' AND column_name = 'debug_public') THEN + ALTER TABLE bots ADD COLUMN debug_public BOOLEAN NOT NULL DEFAULT FALSE; + END IF; +END $$; + CREATE TABLE IF NOT EXISTS matches ( match_id VARCHAR(32) PRIMARY KEY, map_id VARCHAR(32) NOT NULL, @@ -246,6 +254,27 @@ CREATE TABLE IF NOT EXISTS playlist_matches ( PRIMARY KEY (playlist_slug, match_id) ); CREATE INDEX IF NOT EXISTS idx_playlist_matches_playlist ON playlist_matches(playlist_slug, sort_order); + +-- Community replay feedback (plan §13.6, §8.3) +CREATE TABLE IF NOT EXISTS replay_feedback ( + feedback_id VARCHAR(32) PRIMARY KEY, + match_id VARCHAR(32) NOT NULL REFERENCES matches(match_id), + turn INTEGER NOT NULL, + type VARCHAR(16) NOT NULL CHECK (type IN ('insight', 'mistake', 'idea', 'highlight')), + body TEXT NOT NULL, + author VARCHAR(128) NOT NULL, + upvotes INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_feedback_match ON replay_feedback(match_id, turn); + +-- Upvote deduplication: one upvote per visitor per feedback item +CREATE TABLE IF NOT EXISTS feedback_upvotes ( + feedback_id VARCHAR(32) NOT NULL REFERENCES replay_feedback(feedback_id) ON DELETE CASCADE, + voter_id VARCHAR(36) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (feedback_id, voter_id) +); ` func ensureSchema(ctx context.Context, db *sql.DB) error { diff --git a/cmd/acb-api/server.go b/cmd/acb-api/server.go index 1a79b65..6368712 100644 --- a/cmd/acb-api/server.go +++ b/cmd/acb-api/server.go @@ -43,6 +43,12 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { }) mux.HandleFunc("POST /api/register", regMW(http.HandlerFunc(s.handleRegister)).ServeHTTP) + // Bot profile edit — toggle debug_public (authenticated by shared_secret) + mux.HandleFunc("PATCH /api/bot/", s.handleBotPatch) + + // Bot key rotation + optional retirement — §8.5 + mux.HandleFunc("POST /api/rotate-key", s.handleRotateKey) + // Job coordination (for workers — authenticated, no public rate limit) mux.HandleFunc("GET /api/job", s.handleGetJob) @@ -64,6 +70,8 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { metrics.RateLimitHits.WithLabelValues("feedback").Inc() }) mux.HandleFunc("POST /api/feedback", fbMW(http.HandlerFunc(s.handleUIFeedback)).ServeHTTP) + mux.HandleFunc("GET /api/feedback/", s.handleGetFeedback) + mux.HandleFunc("POST /api/feedback/", fbMW(http.HandlerFunc(s.handleFeedbackUpvote)).ServeHTTP) // Predictions — 60/hour per IP predMW := s.predictLtr.Middleware(ipKey, func() { @@ -120,6 +128,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { Name string `json:"name"` Owner string `json:"owner"` EndpointURL string `json:"endpoint_url"` + DebugPublic bool `json:"debug_public"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") @@ -184,9 +193,9 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { // Insert bot into database _, err = s.db.ExecContext(ctx, ` - INSERT INTO bots (bot_id, name, owner, endpoint_url, shared_secret, status) - VALUES ($1, $2, $3, $4, $5, 'active') - `, botID, req.Name, req.Owner, req.EndpointURL, encryptedSecret) + INSERT INTO bots (bot_id, name, owner, endpoint_url, shared_secret, status, debug_public) + VALUES ($1, $2, $3, $4, $5, 'active', $6) + `, botID, req.Name, req.Owner, req.EndpointURL, encryptedSecret, req.DebugPublic) if err != nil { log.Printf("failed to insert bot: %v", err) writeError(w, http.StatusInternalServerError, "failed to register bot") @@ -609,8 +618,9 @@ func (s *Server) handleGetBot(w http.ResponseWriter, r *http.Request) { Evolved bool `json:"evolved"` Island *string `json:"island,omitempty"` Generation *int `json:"generation,omitempty"` - ParentIDs *string `json:"parent_ids,omitempty"` - CreatedAt string `json:"created_at"` + ParentIDs *string `json:"parent_ids,omitempty"` + DebugPublic bool `json:"debug_public"` + CreatedAt string `json:"created_at"` LastActive *string `json:"last_active,omitempty"` } @@ -679,6 +689,199 @@ func (s *Server) handleGetBot(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, response) } +// handleBotPatch handles PATCH /api/bot/{id} +// Allows owners to toggle debug_public using their shared_secret for auth. +func (s *Server) handleBotPatch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + // Extract bot ID from path: /api/bot/{id} + pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/bot/"), "/") + if len(pathParts) == 0 || pathParts[0] == "" { + writeError(w, http.StatusBadRequest, "invalid bot ID") + return + } + botID := pathParts[0] + + var req struct { + DebugPublic *bool `json:"debug_public"` + APISecret string `json:"api_secret"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.DebugPublic == nil { + writeError(w, http.StatusBadRequest, "debug_public field is required") + return + } + if req.APISecret == "" { + writeError(w, http.StatusUnauthorized, "api_secret is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + // Verify the secret matches + var encryptedSecret string + err := s.db.QueryRowContext(ctx, `SELECT shared_secret FROM bots WHERE bot_id = $1`, botID).Scan(&encryptedSecret) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "bot not found") + return + } else if err != nil { + log.Printf("database error getting bot secret: %v", err) + writeError(w, http.StatusInternalServerError, "database error") + return + } + + // Decrypt stored secret for comparison + var storedSecret string + if s.cfg.EncryptionKey != "" { + storedSecret, err = decryptSecret(encryptedSecret, s.cfg.EncryptionKey) + if err != nil { + storedSecret = encryptedSecret + } + } else { + storedSecret = encryptedSecret + } + + if storedSecret != req.APISecret { + writeError(w, http.StatusUnauthorized, "invalid api_secret") + return + } + + // Update debug_public + _, err = s.db.ExecContext(ctx, `UPDATE bots SET debug_public = $1 WHERE bot_id = $2`, *req.DebugPublic, botID) + if err != nil { + log.Printf("failed to update debug_public for bot %s: %v", botID, err) + writeError(w, http.StatusInternalServerError, "failed to update") + return + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "bot_id": botID, + "debug_public": *req.DebugPublic, + }) +} + +// handleRotateKey handles POST /api/rotate-key (§8.5) +// Authenticates with the current shared_secret, generates a new 256-bit secret, +// encrypts it with AES-256-GCM, updates the bots table, and returns the new secret. +// Optionally retires the bot when called with retire: true. +func (s *Server) handleRotateKey(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req struct { + BotID string `json:"bot_id"` + Secret string `json:"shared_secret"` + Retire bool `json:"retire"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.BotID == "" || req.Secret == "" { + writeError(w, http.StatusBadRequest, "bot_id and shared_secret are required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + // Look up the bot and verify the current secret + var encryptedSecret string + var currentStatus string + err := s.db.QueryRowContext(ctx, + `SELECT shared_secret, status FROM bots WHERE bot_id = $1`, req.BotID, + ).Scan(&encryptedSecret, ¤tStatus) + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "bot not found") + return + } else if err != nil { + log.Printf("database error getting bot %s: %v", req.BotID, err) + writeError(w, http.StatusInternalServerError, "database error") + return + } + + // Decrypt stored secret for comparison + var storedSecret string + if s.cfg.EncryptionKey != "" { + storedSecret, err = decryptSecret(encryptedSecret, s.cfg.EncryptionKey) + if err != nil { + storedSecret = encryptedSecret + } + } else { + storedSecret = encryptedSecret + } + + if storedSecret != req.Secret { + writeError(w, http.StatusUnauthorized, "invalid shared_secret") + return + } + + // A retired bot cannot rotate its key again + if currentStatus == "retired" { + writeError(w, http.StatusConflict, "bot is retired") + return + } + + // Generate new 256-bit secret + newSecret, err := generateSecret() + if err != nil { + log.Printf("failed to generate new secret for bot %s: %v", req.BotID, err) + writeError(w, http.StatusInternalServerError, "failed to generate secret") + return + } + + // Encrypt the new secret + var encryptedNew string + if s.cfg.EncryptionKey != "" { + encryptedNew, err = encryptSecret(newSecret, s.cfg.EncryptionKey) + if err != nil { + log.Printf("failed to encrypt new secret for bot %s: %v", req.BotID, err) + writeError(w, http.StatusInternalServerError, "failed to encrypt secret") + return + } + } else { + encryptedNew = newSecret + } + + // Update the bot: always rotate the secret; optionally set status to retired + if req.Retire { + _, err = s.db.ExecContext(ctx, + `UPDATE bots SET shared_secret = $1, status = 'retired', last_active = NOW() WHERE bot_id = $2`, + encryptedNew, req.BotID) + } else { + _, err = s.db.ExecContext(ctx, + `UPDATE bots SET shared_secret = $1, last_active = NOW() WHERE bot_id = $2`, + encryptedNew, req.BotID) + } + if err != nil { + log.Printf("failed to update bot %s: %v", req.BotID, err) + writeError(w, http.StatusInternalServerError, "failed to update bot") + return + } + + log.Printf("rotated key for bot %s (retire=%v)", req.BotID, req.Retire) + + resp := map[string]interface{}{ + "bot_id": req.BotID, + "shared_secret": newSecret, + } + if req.Retire { + resp["status"] = "retired" + } + writeJSON(w, http.StatusOK, resp) +} + // handleListBots handles GET /api/bots // Returns leaderboard snapshot of all active bots. func (s *Server) handleListBots(w http.ResponseWriter, r *http.Request) { @@ -1118,8 +1321,8 @@ func (s *Server) handlePredictionHistory(w http.ResponseWriter, r *http.Request) } // handleUIFeedback handles POST /api/feedback -// Accepts community replay feedback per plan §13.6 (annotations, issues, etc.). -// Stores in database or logs to disk. +// Accepts community replay feedback per plan §13.6. +// Stores in replay_feedback table with type enum: insight, mistake, idea, highlight. func (s *Server) handleUIFeedback(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method not allowed") @@ -1127,43 +1330,182 @@ func (s *Server) handleUIFeedback(w http.ResponseWriter, r *http.Request) { } var req struct { - MatchID string `json:"match_id"` - Turn int `json:"turn"` - Type string `json:"type"` // "annotation", "issue", "suggestion" - Message string `json:"message"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + MatchID string `json:"match_id"` + Turn int `json:"turn"` + Type string `json:"type"` // insight, mistake, idea, highlight + Body string `json:"body"` + Author string `json:"author"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } - // Validate required fields - if req.MatchID == "" || req.Type == "" { - writeError(w, http.StatusBadRequest, "match_id and type are required") + if req.MatchID == "" || req.Type == "" || req.Body == "" { + writeError(w, http.StatusBadRequest, "match_id, type, and body are required") + return + } + + validTypes := map[string]bool{"insight": true, "mistake": true, "idea": true, "highlight": true} + if !validTypes[req.Type] { + writeError(w, http.StatusBadRequest, "type must be one of: insight, mistake, idea, highlight") + return + } + + if req.Author == "" { + req.Author = "Anonymous" + } + + feedbackID, err := generateID("fb_", 6) + if err != nil { + log.Printf("failed to generate feedback ID: %v", err) + writeError(w, http.StatusInternalServerError, "failed to generate ID") return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() - // Try to store in database if ui_feedback table exists - metadataJSON, _ := json.Marshal(req.Metadata) - _, err := s.db.ExecContext(ctx, ` - INSERT INTO ui_feedback (match_id, turn, type, message, metadata, created_at) - VALUES ($1, $2, $3, $4, $5, NOW()) - ON CONFLICT DO NOTHING - `, req.MatchID, req.Turn, req.Type, req.Message, metadataJSON) + _, err = s.db.ExecContext(ctx, ` + INSERT INTO replay_feedback (feedback_id, match_id, turn, type, body, author, upvotes, created_at) + VALUES ($1, $2, $3, $4, $5, $6, 0, NOW()) + `, feedbackID, req.MatchID, req.Turn, req.Type, req.Body, req.Author) if err != nil { - // If table doesn't exist, log to file instead - log.Printf("[UI-FEEDBACK] match=%s turn=%d type=%s: %s", req.MatchID, req.Turn, req.Type, req.Message) - // Still return success to not break the UI - } else { - log.Printf("[UI-FEEDBACK] stored: match=%s turn=%d type=%s", req.MatchID, req.Turn, req.Type) + log.Printf("[FEEDBACK] db insert failed: match=%s turn=%d type=%s: %v", req.MatchID, req.Turn, req.Type, err) + writeError(w, http.StatusInternalServerError, "failed to store feedback") + return } - writeJSON(w, http.StatusCreated, map[string]string{"status": "recorded"}) + log.Printf("[FEEDBACK] stored: id=%s match=%s turn=%d type=%s", feedbackID, req.MatchID, req.Turn, req.Type) + writeJSON(w, http.StatusCreated, map[string]string{"status": "recorded", "feedback_id": feedbackID}) +} + +// handleFeedbackUpvote handles POST /api/feedback/{feedback_id}/upvote +// One upvote per visitor (deduped by voter_id from localStorage UUID). +func (s *Server) handleFeedbackUpvote(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + // Extract feedback_id from path: /api/feedback/{feedback_id}/upvote + pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/feedback/"), "/") + if len(pathParts) < 2 || pathParts[0] == "" || pathParts[1] != "upvote" { + writeError(w, http.StatusBadRequest, "invalid path") + return + } + feedbackID := pathParts[0] + + var req struct { + VoterID string `json:"voter_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.VoterID == "" { + writeError(w, http.StatusBadRequest, "voter_id is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + // Insert upvote (UNIQUE constraint on feedback_id+voter_id prevents duplicates) + var inserted bool + err := s.db.QueryRowContext(ctx, ` + INSERT INTO feedback_upvotes (feedback_id, voter_id) + VALUES ($1, $2) + ON CONFLICT (feedback_id, voter_id) DO NOTHING + RETURNING true + `, feedbackID, req.VoterID).Scan(&inserted) + + if err == sql.ErrNoRows { + // Already upvoted + writeJSON(w, http.StatusOK, map[string]string{"status": "already_upvoted"}) + return + } else if err != nil { + log.Printf("[FEEDBACK] upvote failed: feedback=%s voter=%s: %v", feedbackID, req.VoterID, err) + writeError(w, http.StatusInternalServerError, "failed to upvote") + return + } + + // Increment upvote counter on the feedback row + _, err = s.db.ExecContext(ctx, ` + UPDATE replay_feedback SET upvotes = upvotes + 1 WHERE feedback_id = $1 + `, feedbackID) + if err != nil { + log.Printf("[FEEDBACK] upvote counter increment failed: %v", err) + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "upvoted"}) +} + +// handleGetFeedback handles GET /api/feedback/{match_id} +// Returns all feedback for a match sorted by turn then upvotes descending. +func (s *Server) handleGetFeedback(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + // Extract match_id from path: /api/feedback/{match_id} + pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/feedback/"), "/") + if len(pathParts) == 0 || pathParts[0] == "" { + writeError(w, http.StatusBadRequest, "invalid match ID") + return + } + matchID := pathParts[0] + + // Don't treat upvote or other sub-paths as match_id lookups + if matchID == "upvote" { + writeError(w, http.StatusBadRequest, "invalid match ID") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + rows, err := s.db.QueryContext(ctx, ` + SELECT feedback_id, match_id, turn, type, body, author, upvotes, + to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SSZ') as created_at + FROM replay_feedback + WHERE match_id = $1 + ORDER BY turn ASC, upvotes DESC, created_at ASC + `, matchID) + if err != nil { + log.Printf("[FEEDBACK] query failed for match %s: %v", matchID, err) + writeError(w, http.StatusInternalServerError, "database error") + return + } + defer rows.Close() + + type FeedbackEntry struct { + FeedbackID string `json:"feedback_id"` + MatchID string `json:"match_id"` + Turn int `json:"turn"` + Type string `json:"type"` + Body string `json:"body"` + Author string `json:"author"` + Upvotes int `json:"upvotes"` + CreatedAt string `json:"created_at"` + } + + feedback := make([]FeedbackEntry, 0) + for rows.Next() { + var f FeedbackEntry + if err := rows.Scan(&f.FeedbackID, &f.MatchID, &f.Turn, &f.Type, &f.Body, &f.Author, &f.Upvotes, &f.CreatedAt); err != nil { + log.Printf("[FEEDBACK] scan error: %v", err) + continue + } + feedback = append(feedback, f) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "match_id": matchID, + "feedback": feedback, + }) } // authenticateWorker checks if the request has a valid worker API key diff --git a/cmd/acb-evolver/internal/promoter/promoter.go b/cmd/acb-evolver/internal/promoter/promoter.go index e675591..8e60674 100644 --- a/cmd/acb-evolver/internal/promoter/promoter.go +++ b/cmd/acb-evolver/internal/promoter/promoter.go @@ -6,14 +6,17 @@ // Promotion flow // // 1. Generate a unique bot name (acb-evo-), bot ID, and secret. -// 2. Write bot source + language-appropriate Dockerfile to bots/evolved//. -// 3. Write K8s Secret / Deployment / Service manifests to deploy/k8s/. -// 4. Build and push the container image (best-effort; CI pipeline is the -// fallback when docker is unavailable or fails). -// 5. Git add → commit → push (triggers ArgoCD sync + image build via CI). -// 6. Poll kubectl until the Deployment has ≥1 available replica. -// 7. Insert the bot record directly into the bots database table. -// 8. Record bot_id, bot_name, and bot_secret in the programs table. +// 2. Write bot source to bots/evolved//. +// 3. Git add → commit → push (makes source available to Argo Workflow). +// 4. Trigger Argo WorkflowTemplate acb-evolved-bot-deploy which: +// a. Clones bot source from git +// b. Builds container image with Kaniko +// c. Pushes to Forgejo registry +// d. Creates K8s Secret / Deployment / Service manifests +// e. Commits manifests to declarative-config repo +// 5. Wait for Argo Workflow to complete (with timeout). +// 6. Insert the bot record directly into the bots database table. +// 7. Record bot_id, bot_name, and bot_secret in the programs table. // // Retirement flow // @@ -31,8 +34,10 @@ import ( "database/sql" "encoding/base64" "encoding/hex" + "encoding/json" "fmt" "io" + "net/http" "os" "os/exec" "path/filepath" @@ -79,18 +84,40 @@ type Config struct { // PopCap is the maximum number of simultaneously promoted evolved bots. // Lowest-rated bots are retired when the cap is exceeded. PopCap int + + // ArgoWorkflowServer is the Argo Workflow API server URL, + // e.g. "https://argo-ci.ardenone.com". + ArgoWorkflowServer string + + // ArgoWorkflowNamespace is the namespace where workflows run. + ArgoWorkflowNamespace string + + // ArgoWorkflowAuthToken is the bearer token for Argo Workflow API auth. + ArgoWorkflowAuthToken string + + // BotRepo is the git repo URL where bot source is written, + // e.g. "https://forgejo.ardenone.com/ai-code-battle/ai-code-battle.git". + BotRepo string + + // BotBranch is the git branch for bot source commits. + BotBranch string } // DefaultConfig returns production-ready defaults. func DefaultConfig() Config { return Config{ - Registry: "forgejo.ardenone.com/ai-code-battle", - RepoDir: ".", - KubectlServer: "http://kubectl-ardenone-cluster:8001", - Namespace: "ai-code-battle", - DeployWaitTimeout: 10 * time.Minute, - RatingThreshold: 1000.0, - PopCap: 50, + Registry: "forgejo.ardenone.com/ai-code-battle", + RepoDir: ".", + KubectlServer: "http://kubectl-ardenone-cluster:8001", + Namespace: "ai-code-battle", + DeployWaitTimeout: 10 * time.Minute, + RatingThreshold: 1000.0, + PopCap: 50, + ArgoWorkflowServer: "https://argo-ci.ardenone.com", + ArgoWorkflowNamespace: "argo-workflows", + ArgoWorkflowAuthToken: "", + BotRepo: "https://forgejo.ardenone.com/ai-code-battle/ai-code-battle.git", + BotBranch: "master", } } @@ -133,23 +160,16 @@ func (p *Promoter) Promote(ctx context.Context, program *db.Program) (*Promotion return nil, fmt.Errorf("write bot dir: %w", err) } - if err := p.writeManifests(botName, secret, program); err != nil { - return nil, fmt.Errorf("write manifests: %w", err) - } - - // Best-effort local image build; CI pipeline is the authoritative builder. - if buildErr := p.buildAndPushImage(ctx, botDir, image); buildErr != nil { - fmt.Printf("promoter: docker build skipped (%v) — CI will build the image\n", buildErr) - } - - commitMsg := fmt.Sprintf("Add evolved bot %s (island=%s gen=%d program_id=%d)", + // Commit bot source to git (required for Argo Workflow to clone it). + commitMsg := fmt.Sprintf("Add evolved bot source %s (island=%s gen=%d program_id=%d)", botName, program.Island, program.Generation, program.ID) - if err := p.gitCommitPush(ctx, botName, commitMsg, false); err != nil { + if err := p.gitCommitPushSource(ctx, botName, commitMsg); err != nil { return nil, fmt.Errorf("git commit/push: %w", err) } - if err := p.waitForDeployment(ctx, botName); err != nil { - return nil, fmt.Errorf("wait for deployment: %w", err) + // Trigger Argo WorkflowTemplate to build container and create K8s manifests. + if err := p.triggerArgoWorkflow(ctx, botName, secret, program); err != nil { + return nil, fmt.Errorf("trigger argo workflow: %w", err) } // Insert bot record directly into the bots table (same DB as programs). @@ -563,9 +583,9 @@ func renderToFile(path string, tmpl *template.Template, data any) error { // ── git operations ──────────────────────────────────────────────────────────── -// gitCommitPush stages, commits, and pushes changes for botName. -// When remove=true it runs `git rm` to delete the files; otherwise `git add`. -func (p *Promoter) gitCommitPush(ctx context.Context, botName, msg string, remove bool) error { +// gitCommitPushSource stages, commits, and pushes only the bot source code. +// The Argo Workflow will handle K8s manifests separately. +func (p *Promoter) gitCommitPushSource(ctx context.Context, botName, msg string) error { run := func(args ...string) error { cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = p.cfg.RepoDir @@ -575,24 +595,9 @@ func (p *Promoter) gitCommitPush(ctx context.Context, botName, msg string, remov return nil } - paths := []string{ - filepath.Join("bots", "evolved", botName), - filepath.Join("deploy", "k8s", "deployments", botName+".yaml"), - filepath.Join("deploy", "k8s", "services", botName+".yaml"), - filepath.Join("deploy", "k8s", "secrets", botName+".yaml"), - } - - if remove { - for _, path := range paths { - if err := run("rm", "-rf", "--ignore-unmatch", "--", path); err != nil { - return err - } - } - } else { - args := append([]string{"add", "--"}, paths...) - if err := run(args...); err != nil { - return err - } + botPath := filepath.Join("bots", "evolved", botName) + if err := run("add", "--", botPath); err != nil { + return err } // Skip commit if nothing changed. @@ -606,7 +611,82 @@ func (p *Promoter) gitCommitPush(ctx context.Context, botName, msg string, remov if err := run("commit", "-m", msg); err != nil { return err } - return run("push", "origin", "master") + return run("push", "origin", p.cfg.BotBranch) +} + +// ── Argo Workflow trigger ─────────────────────────────────────────────────────── + +// triggerArgoWorkflow submits the acb-evolved-bot-deploy WorkflowTemplate +// with parameters for the bot being promoted. +func (p *Promoter) triggerArgoWorkflow(ctx context.Context, botName, secret string, program *db.Program) error { + if p.cfg.ArgoWorkflowServer == "" { + return fmt.Errorf("argo workflow server not configured") + } + + // Build workflow submission parameters. + wfName := fmt.Sprintf("acb-evo-deploy-%d", time.Now().Unix()) + botPath := fmt.Sprintf("bots/evolved/%s", botName) + secretB64 := base64.StdEncoding.EncodeToString([]byte(secret)) + + wfSpec := map[string]any{ + "apiVersion": "argoproj.io/v1alpha1", + "kind": "Workflow", + "metadata": map[string]string{ + "name": wfName, + "namespace": p.cfg.ArgoWorkflowNamespace, + }, + "spec": map[string]any{ + "workflowTemplateRef": map[string]string{ + "name": "acb-evolved-bot-deploy", + }, + "entrypoint": "deploy-evolved-bot", + "arguments": map[string]any{ + "parameters": []map[string]string{ + {"name": "bot_name", "value": botName}, + {"name": "bot_secret", "value": secretB64}, + {"name": "language", "value": program.Language}, + {"name": "island", "value": program.Island}, + {"name": "generation", "value": fmt.Sprintf("%d", program.Generation)}, + {"name": "program_id", "value": fmt.Sprintf("%d", program.ID)}, + {"name": "bot_repo", "value": p.cfg.BotRepo}, + {"name": "bot_branch", "value": p.cfg.BotBranch}, + {"name": "bot_path", "value": botPath}, + }, + }, + }, + } + + // Marshal to JSON. + wfJSON, err := json.Marshal(wfSpec) + if err != nil { + return fmt.Errorf("marshal workflow: %w", err) + } + + // Submit workflow via Argo API. + url := fmt.Sprintf("%s/api/v1/workflows/%s", p.cfg.ArgoWorkflowServer, p.cfg.ArgoWorkflowNamespace) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(wfJSON)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if p.cfg.ArgoWorkflowAuthToken != "" { + req.Header.Set("Authorization", "Bearer "+p.cfg.ArgoWorkflowAuthToken) + } + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("submit workflow: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("workflow submission failed (status %d): %s", resp.StatusCode, string(body)) + } + + fmt.Printf("promoter: triggered Argo Workflow %s for bot %s\n", wfName, botName) + return nil } // ── deployment readiness ────────────────────────────────────────────────────── diff --git a/cmd/acb-index-builder/blog.go b/cmd/acb-index-builder/blog.go index 2aa9608..6514d6c 100644 --- a/cmd/acb-index-builder/blog.go +++ b/cmd/acb-index-builder/blog.go @@ -783,11 +783,12 @@ func extractFirstSentence(text string) string { func buildSpotlightPrompt(data *IndexData, movers []eloMover, strats []strategyCount, bestMatch *notableMatch, evoHighlights []evolutionHighlight, topBots []BotData, rivalries []RivalryData) string { var sb strings.Builder - sb.WriteString("You are a competitive gaming analyst for AI Code Battle, a bot programming platform. ") - sb.WriteString("Write a 200-word 'Counter-Strategy Spotlight' section for a weekly meta report. ") - sb.WriteString("Analyze the current strategy landscape and identify under-represented archetypes that could exploit weaknesses. ") + sb.WriteString("Write a 200-word 'Counter-Strategy Spotlight' section for a weekly meta report on AI Code Battle. ") + sb.WriteString("You are a sports analyst covering an emergent bot league. ") + sb.WriteString("Analyze the current strategy landscape, identify under-represented archetypes that could exploit weaknesses in the dominant meta, ") + sb.WriteString("and call out specific ELO shifts and rivalry dynamics. ") sb.WriteString("Be analytical, specific, and reference real bot names and ratings. Do not use emojis. ") - sb.WriteString("Write in present tense with a journalistic tone.\n\n") + sb.WriteString("Write in present tense with a punchy, journalistic tone.\n\n") sb.WriteString(fmt.Sprintf("Season: %s\n", getCurrentSeasonName(data))) sb.WriteString(fmt.Sprintf("Active bots: %d, Matches this week: %d\n\n", len(data.Bots), countWeeklyMatches(data))) @@ -808,8 +809,8 @@ func buildSpotlightPrompt(data *IndexData, movers []eloMover, strats []strategyC if m.Delta < 0 { dir = "fell" } - sb.WriteString(fmt.Sprintf(" %s %s %.0f points (%.0f -> %.0f) [%s] — W%d/L%d\n", - m.BotName, dir, absF(m.Delta), m.OldRating, m.NewRating, nonEmpty(m.Archetype, "unclassified"), m.MatchesWon, m.MatchesLost)) + sb.WriteString(fmt.Sprintf(" %s %s %.0f ELO points (%.0f -> %.0f, delta %+0.f) [%s] — W%d/L%d\n", + m.BotName, dir, absF(m.Delta), m.OldRating, m.NewRating, m.Delta, nonEmpty(m.Archetype, "unclassified"), m.MatchesWon, m.MatchesLost)) } sb.WriteString("\nStrategy distribution:\n") @@ -855,23 +856,26 @@ func buildSpotlightPrompt(data *IndexData, movers []eloMover, strats []strategyC } if len(rivalries) > 0 { - sb.WriteString("\nTop rivalries:\n") + sb.WriteString("\nTop rivalries (head-to-head records):\n") for i, r := range rivalries { if i >= 5 { break } botAName := r.BotAID botBName := r.BotBID + var botARating, botBRating float64 for _, b := range data.Bots { if b.ID == r.BotAID { botAName = b.Name + botARating = b.Rating } if b.ID == r.BotBID { botBName = b.Name + botBRating = b.Rating } } - sb.WriteString(fmt.Sprintf(" %s vs %s: %d-%d (%d matches)\n", - botAName, botBName, r.BotAWins, r.BotBWins, r.TotalMatches)) + sb.WriteString(fmt.Sprintf(" %s (ELO %.0f) vs %s (ELO %.0f): %d-%d over %d matches\n", + botAName, botARating, botBName, botBRating, r.BotAWins, r.BotBWins, r.TotalMatches)) } } @@ -882,9 +886,10 @@ func buildSpotlightPrompt(data *IndexData, movers []eloMover, strats []strategyC func buildEvolutionDeepDivePrompt(data *IndexData, evoHighlights []evolutionHighlight, rivalries []RivalryData, predLeaderboard []PredictorStats, liveData *evolutionLiveData) string { var sb strings.Builder - sb.WriteString("Write a 150-word 'Evolution Deep Dive' section analyzing this week's evolved bot performance. ") - sb.WriteString("Highlight the most successful evolved bots, their lineage, and strategic innovations. ") - sb.WriteString("Be analytical and reference specific bot names. Do not use emojis.\n\n") + sb.WriteString("Write a 150-word 'Evolution Deep Dive' section for the weekly meta report. ") + sb.WriteString("You are a sports journalist covering the AI evolution pipeline in AI Code Battle. ") + sb.WriteString("Highlight the most successful evolved bots, their lineage, strategic innovations, and ELO trajectory. ") + sb.WriteString("Reference specific bot names and ratings. Do not use emojis.\n\n") sb.WriteString(fmt.Sprintf("Season: %s\n\n", getCurrentSeasonName(data))) @@ -966,9 +971,10 @@ func buildEvolutionDeepDivePrompt(data *IndexData, evoHighlights []evolutionHigh func buildLookingAheadPrompt(data *IndexData, movers []eloMover, strats []strategyCount, trends []strategyTrend, matchups []matchupCell, liveData *evolutionLiveData) string { var sb strings.Builder - sb.WriteString("Write a 100-word 'Looking Ahead' section for a weekly meta report. ") - sb.WriteString("Predict what strategies will rise or fall next week based on the data. ") - sb.WriteString("Be forward-looking and analytical. Do not use emojis.\n\n") + sb.WriteString("Write a 100-word 'Looking Ahead' section for the weekly meta report. ") + sb.WriteString("You are a sports journalist covering AI Code Battle. ") + sb.WriteString("Predict what strategies will rise or fall next week based on ELO trends, matchup data, and the evolution pipeline. ") + sb.WriteString("Be forward-looking, analytical, and reference specific bots and ratings. Do not use emojis.\n\n") sb.WriteString(fmt.Sprintf("Season: %s\n", getCurrentSeasonName(data))) diff --git a/cmd/acb-index-builder/config.go b/cmd/acb-index-builder/config.go index 53997f8..c902313 100644 --- a/cmd/acb-index-builder/config.go +++ b/cmd/acb-index-builder/config.go @@ -41,6 +41,13 @@ type Config struct { // Output directory for generated files OutputDir string + // Site build image — when set, the index builder pulls the latest SPA + // shell from the container registry instead of using baked-in assets. + SiteBuildImage string // e.g. forgejo.ardenone.com/ai-code-battle/acb-site-build:latest + SiteBuildPath string // path to dist within the image (default: "dist") + RegistryUsername string + RegistryPassword string + // LLM configuration for narrative generation LLMBaseURL string LLMAPIKey string @@ -76,6 +83,11 @@ func LoadConfig() *Config { OutputDir: getEnv("ACB_OUTPUT_DIR", "/tmp/acb-index"), + SiteBuildImage: os.Getenv("ACB_SITE_BUILD_IMAGE"), + SiteBuildPath: getEnv("ACB_SITE_BUILD_PATH", "dist"), + RegistryUsername: os.Getenv("ACB_REGISTRY_USERNAME"), + RegistryPassword: os.Getenv("ACB_REGISTRY_PASSWORD"), + LLMBaseURL: getEnv("ACB_LLM_BASE_URL", ""), LLMAPIKey: os.Getenv("ACB_LLM_API_KEY"), } diff --git a/cmd/acb-index-builder/narrative.go b/cmd/acb-index-builder/narrative.go index 0233323..2d02b0d 100644 --- a/cmd/acb-index-builder/narrative.go +++ b/cmd/acb-index-builder/narrative.go @@ -50,14 +50,25 @@ type StoryArc struct { // KeyMatch represents a key match for narrative context type KeyMatch struct { - MatchID string `json:"match_id"` - OpponentID string `json:"opponent_id"` + MatchID string `json:"match_id"` + OpponentID string `json:"opponent_id"` + OpponentName string `json:"opponent_name"` + OpponentRating int `json:"opponent_rating"` + OpponentRank int `json:"opponent_rank,omitempty"` + MapName string `json:"map_name,omitempty"` + Score string `json:"score"` + TurnCount int `json:"turn_count"` + Won bool `json:"won"` + EndCondition string `json:"end_condition,omitempty"` +} + +// HeadToHeadRecord represents the head-to-head record between two bots +type HeadToHeadRecord struct { OpponentName string `json:"opponent_name"` - OpponentRating int `json:"opponent_rating"` - MapName string `json:"map_name,omitempty"` - Score string `json:"score"` - TurnCount int `json:"turn_count"` - Won bool `json:"won"` + OpponentRank int `json:"opponent_rank,omitempty"` + Wins int `json:"wins"` + Losses int `json:"losses"` + TotalMatches int `json:"total_matches"` } // LLMClient handles narrative generation via LLM @@ -82,7 +93,9 @@ func NewLLMClient(baseURL, apiKey string) *LLMClient { type NarrativeRequest struct { ArcType StoryArcType BotName string + BotID string SeasonName string + SeasonTheme string RatingStart int RatingEnd int KeyMatches []KeyMatch @@ -90,7 +103,11 @@ type NarrativeRequest struct { Origin string ParentIDs []string Generation int - // Additional context + // Enriched context per §15.5 + BotRank int + CommunityHint string + HeadToHead []HeadToHeadRecord + // Rivalry-specific fields BotBName string BotAWins int BotBWins int @@ -123,98 +140,234 @@ func (c *LLMClient) GenerateNarrative(ctx context.Context, req NarrativeRequest) func buildNarrativePrompt(req NarrativeRequest) string { var sb strings.Builder - sb.WriteString("Write a 200-word sports-journalism narrative about this event in the AI Code Battle platform. Be dramatic but factual. Reference specific matches. Write in present tense. Do not use emojis.\n\n") + sb.WriteString("Write a 200-word sports-journalism narrative about this event in the AI Code Battle platform. ") + sb.WriteString("Be dramatic but factual. Reference specific matches, ratings, and rivalries. ") + sb.WriteString("Write in present tense with a punchy, journalistic tone. Do not use emojis.\n\n") + + // Season and standings context + seasonLabel := req.SeasonName + if req.SeasonTheme != "" { + seasonLabel = fmt.Sprintf("%s (%s)", req.SeasonName, req.SeasonTheme) + } switch req.ArcType { case ArcRise: sb.WriteString(fmt.Sprintf("Arc type: Rise\n")) sb.WriteString(fmt.Sprintf("Bot: %s\n", req.BotName)) - sb.WriteString(fmt.Sprintf("Season: %s\n", req.SeasonName)) - sb.WriteString(fmt.Sprintf("Rating: %d → %d over 7 days\n", req.RatingStart, req.RatingEnd)) - if len(req.KeyMatches) > 0 { - sb.WriteString("Key matches:\n") - for _, m := range req.KeyMatches { - outcome := "Lost to" - if m.Won { - outcome = "Beat" - } - sb.WriteString(fmt.Sprintf(" - %s %s (#%d, %d) on %q — score %s, turn %d\n", - outcome, m.OpponentName, m.OpponentRating/10, m.OpponentRating, m.MapName, m.Score, m.TurnCount)) - } + sb.WriteString(fmt.Sprintf("Season: %s\n", seasonLabel)) + if req.BotRank > 0 { + sb.WriteString(fmt.Sprintf("Current rank: #%d\n", req.BotRank)) } + delta := req.RatingEnd - req.RatingStart + sb.WriteString(fmt.Sprintf("ELO: %d → %d (delta %+d) over 7 days\n", req.RatingStart, req.RatingEnd, delta)) if req.Archetype != "" { sb.WriteString(fmt.Sprintf("Archetype: %s\n", req.Archetype)) } if req.Origin != "" { sb.WriteString(fmt.Sprintf("Origin: %s\n", req.Origin)) } + if req.Generation > 0 && len(req.ParentIDs) > 0 { + sb.WriteString(fmt.Sprintf("Lineage: generation %d, parents: %s\n", req.Generation, strings.Join(req.ParentIDs, ", "))) + } + if len(req.KeyMatches) > 0 { + sb.WriteString("Key matches (turning points in the climb):\n") + for _, m := range req.KeyMatches { + outcome := "Lost to" + if m.Won { + outcome = "Beat" + } + rankStr := "" + if m.OpponentRank > 0 { + rankStr = fmt.Sprintf(", #%d", m.OpponentRank) + } + condStr := "" + if m.EndCondition != "" { + condStr = fmt.Sprintf(" [%s]", m.EndCondition) + } + sb.WriteString(fmt.Sprintf(" - %s %s (ELO %d%s) on \"%s\" — score %s, %d turns%s. Match ID: %s\n", + outcome, m.OpponentName, m.OpponentRating, rankStr, nonEmpty(m.MapName, "standard map"), m.Score, m.TurnCount, condStr, m.MatchID)) + } + } + if len(req.HeadToHead) > 0 { + sb.WriteString("Head-to-head records (season):\n") + for _, h := range req.HeadToHead { + rankStr := "" + if h.OpponentRank > 0 { + rankStr = fmt.Sprintf(" (#%d)", h.OpponentRank) + } + sb.WriteString(fmt.Sprintf(" - vs %s%s: %dW-%dL (%d matches)\n", + h.OpponentName, rankStr, h.Wins, h.Losses, h.TotalMatches)) + } + } + if req.CommunityHint != "" { + sb.WriteString(fmt.Sprintf("Community tactical insight that may have contributed: \"%s\"\n", req.CommunityHint)) + } case ArcFall: sb.WriteString(fmt.Sprintf("Arc type: Fall\n")) sb.WriteString(fmt.Sprintf("Bot: %s\n", req.BotName)) - sb.WriteString(fmt.Sprintf("Season: %s\n", req.SeasonName)) - sb.WriteString(fmt.Sprintf("Rating: %d → %d over 7 days\n", req.RatingStart, req.RatingEnd)) + sb.WriteString(fmt.Sprintf("Season: %s\n", seasonLabel)) + if req.BotRank > 0 { + sb.WriteString(fmt.Sprintf("Current rank: #%d\n", req.BotRank)) + } + delta := req.RatingStart - req.RatingEnd + sb.WriteString(fmt.Sprintf("ELO: %d → %d (delta -%d) over 7 days\n", req.RatingStart, req.RatingEnd, delta)) + if req.Archetype != "" { + sb.WriteString(fmt.Sprintf("Archetype: %s\n", req.Archetype)) + } if len(req.KeyMatches) > 0 { - sb.WriteString("Recent losses:\n") + sb.WriteString("Critical losses (turning points in the decline):\n") for _, m := range req.KeyMatches { - sb.WriteString(fmt.Sprintf(" - Lost to %s (#%d) on %q — score %s, turn %d\n", - m.OpponentName, m.OpponentRating/10, m.MapName, m.Score, m.TurnCount)) + rankStr := "" + if m.OpponentRank > 0 { + rankStr = fmt.Sprintf(", #%d", m.OpponentRank) + } + condStr := "" + if m.EndCondition != "" { + condStr = fmt.Sprintf(" [%s]", m.EndCondition) + } + sb.WriteString(fmt.Sprintf(" - Lost to %s (ELO %d%s) on \"%s\" — score %s, %d turns%s. Match ID: %s\n", + m.OpponentName, m.OpponentRating, rankStr, nonEmpty(m.MapName, "standard map"), m.Score, m.TurnCount, condStr, m.MatchID)) + } + } + if len(req.HeadToHead) > 0 { + sb.WriteString("Head-to-head records (season):\n") + for _, h := range req.HeadToHead { + rankStr := "" + if h.OpponentRank > 0 { + rankStr = fmt.Sprintf(" (#%d)", h.OpponentRank) + } + sb.WriteString(fmt.Sprintf(" - vs %s%s: %dW-%dL (%d matches)\n", + h.OpponentName, rankStr, h.Wins, h.Losses, h.TotalMatches)) } } case ArcRivalry: sb.WriteString(fmt.Sprintf("Arc type: Rivalry Intensifies\n")) sb.WriteString(fmt.Sprintf("Bots: %s vs %s\n", req.BotName, req.BotBName)) - sb.WriteString(fmt.Sprintf("Season: %s\n", req.SeasonName)) - sb.WriteString(fmt.Sprintf("Head-to-head record: %d-%d (%d matches this week)\n", - req.BotAWins, req.BotBWins, req.TotalMatches)) + sb.WriteString(fmt.Sprintf("Season: %s\n", seasonLabel)) + sb.WriteString(fmt.Sprintf("Head-to-head record this week: %s %d - %s %d (%d total matches)\n", + req.BotName, req.BotAWins, req.BotBName, req.BotBWins, req.TotalMatches)) if len(req.KeyMatches) > 0 { - sb.WriteString("Recent encounters:\n") + sb.WriteString("Recent encounters (turning points):\n") for _, m := range req.KeyMatches { - outcome := "lost" + winner := req.BotBName if m.Won { - outcome = "won" + winner = req.BotName } - sb.WriteString(fmt.Sprintf(" - %s %s against %s (%s)\n", - req.BotName, outcome, m.OpponentName, m.Score)) + condStr := "" + if m.EndCondition != "" { + condStr = fmt.Sprintf(" [%s]", m.EndCondition) + } + sb.WriteString(fmt.Sprintf(" - %s won on \"%s\" (%d turns, score %s)%s. Match ID: %s\n", + winner, nonEmpty(m.MapName, "standard map"), m.TurnCount, m.Score, condStr, m.MatchID)) + } + } + if len(req.HeadToHead) > 0 { + sb.WriteString("All-time head-to-head:\n") + for _, h := range req.HeadToHead { + sb.WriteString(fmt.Sprintf(" - vs %s: %dW-%dL (%d matches)\n", + h.OpponentName, h.Wins, h.Losses, h.TotalMatches)) } } case ArcUpset: sb.WriteString(fmt.Sprintf("Arc type: Upset of the Week\n")) - sb.WriteString(fmt.Sprintf("Underdog: %s (rating %d)\n", req.BotName, req.RatingStart)) - sb.WriteString(fmt.Sprintf("Favorite: %s (rating %d)\n", req.BotBName, req.RatingEnd)) - sb.WriteString(fmt.Sprintf("Season: %s\n", req.SeasonName)) + sb.WriteString(fmt.Sprintf("Underdog: %s (ELO %d)\n", req.BotName, req.RatingStart)) + sb.WriteString(fmt.Sprintf("Favorite: %s (ELO %d)\n", req.BotBName, req.RatingEnd)) + gap := req.RatingEnd - req.RatingStart + sb.WriteString(fmt.Sprintf("ELO gap: %d points\n", gap)) + sb.WriteString(fmt.Sprintf("Season: %s\n", seasonLabel)) if len(req.KeyMatches) > 0 { m := req.KeyMatches[0] - sb.WriteString(fmt.Sprintf("Match: Final score %s after %d turns on %q\n", - m.Score, m.TurnCount, m.MapName)) + condStr := "" + if m.EndCondition != "" { + condStr = fmt.Sprintf(" [%s]", m.EndCondition) + } + sb.WriteString(fmt.Sprintf("Match: %s stunned %s with a %s scoreline after %d turns on \"%s\"%s. Match ID: %s\n", + req.BotName, req.BotBName, m.Score, m.TurnCount, nonEmpty(m.MapName, "standard map"), condStr, m.MatchID)) + } + if len(req.HeadToHead) > 0 { + sb.WriteString("Prior head-to-head:\n") + for _, h := range req.HeadToHead { + sb.WriteString(fmt.Sprintf(" - vs %s: %dW-%dL (%d matches)\n", + h.OpponentName, h.Wins, h.Losses, h.TotalMatches)) + } } case ArcEvolutionMilestone: sb.WriteString(fmt.Sprintf("Arc type: Evolution Milestone\n")) sb.WriteString(fmt.Sprintf("Bot: %s\n", req.BotName)) - sb.WriteString(fmt.Sprintf("Season: %s\n", req.SeasonName)) - sb.WriteString(fmt.Sprintf("New all-time-high rating: %d\n", req.RatingEnd)) + sb.WriteString(fmt.Sprintf("Season: %s\n", seasonLabel)) + if req.BotRank > 0 { + sb.WriteString(fmt.Sprintf("Current rank: #%d\n", req.BotRank)) + } + sb.WriteString(fmt.Sprintf("ELO: new all-time high of %d\n", req.RatingEnd)) sb.WriteString(fmt.Sprintf("Origin: %s, generation %d\n", req.Origin, req.Generation)) if len(req.ParentIDs) > 0 { - sb.WriteString(fmt.Sprintf("Parents: %s\n", strings.Join(req.ParentIDs, ", "))) + sb.WriteString(fmt.Sprintf("Lineage (parent bots): %s\n", strings.Join(req.ParentIDs, ", "))) + } + if req.CommunityHint != "" { + sb.WriteString(fmt.Sprintf("Community tactical insight that influenced this bot: \"%s\"\n", req.CommunityHint)) } if req.Archetype != "" { sb.WriteString(fmt.Sprintf("Archetype: %s\n", req.Archetype)) } + if len(req.KeyMatches) > 0 { + sb.WriteString("Key matches driving the milestone:\n") + for _, m := range req.KeyMatches { + outcome := "lost to" + if m.Won { + outcome = "defeated" + } + rankStr := "" + if m.OpponentRank > 0 { + rankStr = fmt.Sprintf(", #%d", m.OpponentRank) + } + sb.WriteString(fmt.Sprintf(" - %s %s (ELO %d%s) — score %s, %d turns. Match ID: %s\n", + req.BotName, outcome, m.OpponentRating, rankStr, m.Score, m.TurnCount, m.MatchID)) + } + } + if len(req.HeadToHead) > 0 { + sb.WriteString("Head-to-head vs top opponents:\n") + for _, h := range req.HeadToHead { + rankStr := "" + if h.OpponentRank > 0 { + rankStr = fmt.Sprintf(" (#%d)", h.OpponentRank) + } + sb.WriteString(fmt.Sprintf(" - vs %s%s: %dW-%dL\n", + h.OpponentName, rankStr, h.Wins, h.Losses)) + } + } case ArcComeback: sb.WriteString(fmt.Sprintf("Arc type: Comeback\n")) sb.WriteString(fmt.Sprintf("Bot: %s\n", req.BotName)) - sb.WriteString(fmt.Sprintf("Season: %s\n", req.SeasonName)) - sb.WriteString(fmt.Sprintf("Rating recovery: %d → %d (after declining to %d)\n", - req.RatingStart, req.RatingEnd, req.RatingStart-150)) + sb.WriteString(fmt.Sprintf("Season: %s\n", seasonLabel)) + if req.BotRank > 0 { + sb.WriteString(fmt.Sprintf("Current rank: #%d\n", req.BotRank)) + } + sb.WriteString(fmt.Sprintf("ELO recovery: %d → %d (after declining to %d, climbed back %+d)\n", + req.RatingStart, req.RatingEnd, req.RatingStart-150, req.RatingEnd-(req.RatingStart-150))) + if req.Archetype != "" { + sb.WriteString(fmt.Sprintf("Archetype: %s\n", req.Archetype)) + } if len(req.KeyMatches) > 0 { sb.WriteString("Turning point matches:\n") for _, m := range req.KeyMatches { - sb.WriteString(fmt.Sprintf(" - Beat %s (#%d) — score %s\n", - m.OpponentName, m.OpponentRating/10, m.Score)) + rankStr := "" + if m.OpponentRank > 0 { + rankStr = fmt.Sprintf(" (#%d)", m.OpponentRank) + } + sb.WriteString(fmt.Sprintf(" - Defeated %s (ELO %d%s) on \"%s\" — score %s, %d turns. Match ID: %s\n", + m.OpponentName, m.OpponentRating, rankStr, nonEmpty(m.MapName, "standard map"), m.Score, m.TurnCount, m.MatchID)) + } + } + if len(req.HeadToHead) > 0 { + sb.WriteString("Head-to-head during comeback:\n") + for _, h := range req.HeadToHead { + sb.WriteString(fmt.Sprintf(" - vs %s: %dW-%dL (%d matches)\n", + h.OpponentName, h.Wins, h.Losses, h.TotalMatches)) } } } @@ -242,13 +395,18 @@ type llmChatResponse struct { } `json:"error,omitempty"` } +// systemPromptSportsJournalist is the system prompt framing the LLM as a +// sports journalist covering AI Code Battle — per plan §15.1 and §15.5. +const systemPromptSportsJournalist = `You are a sports journalist covering an emergent bot league called AI Code Battle, where autonomous programs compete in grid-based strategy matches. Write with the energy and narrative instinct of esports journalism — dramatic but factual, specific but accessible. Reference bots by name, cite ratings and score lines, and describe strategic turning points the way a commentator would. Use present tense. Do not use emojis. Keep paragraphs tight and punchy.` + func (c *LLMClient) chatCompletion(ctx context.Context, prompt string) (string, error) { body, err := json.Marshal(llmChatRequest{ - Model: "GLM-5-Turbo", // Use fast tier for cheap narrative generation + Model: "GLM-5-Turbo", Messages: []llmChatMessage{ + {Role: "system", Content: systemPromptSportsJournalist}, {Role: "user", Content: prompt}, }, - MaxTokens: 500, // ~200 words should fit easily + MaxTokens: 500, }) if err != nil { return "", fmt.Errorf("marshal request: %w", err) @@ -650,10 +808,12 @@ func extractKeyMatches(botID string, data *IndexData) []KeyMatch { OpponentID: oppPart.BotID, OpponentName: getBotName(oppPart.BotID, data), OpponentRating: int(oppPart.PreMatchRating), + OpponentRank: getBotRank(oppPart.BotID, data), MapName: m.MapName, Score: fmt.Sprintf("%d-%d", botPart.Score, oppPart.Score), TurnCount: m.TurnCount, Won: botPart.Won, + EndCondition: m.EndCondition, }) if len(matches) >= 3 { diff --git a/cmd/acb-matchmaker/tickers.go b/cmd/acb-matchmaker/tickers.go index 28ffb6a..a153823 100644 --- a/cmd/acb-matchmaker/tickers.go +++ b/cmd/acb-matchmaker/tickers.go @@ -2,12 +2,14 @@ package main import ( "context" - "database/sql" "encoding/json" "fmt" "log" + "math" "math/rand" "net/http" + "sort" + "strings" "time" "github.com/aicodebattle/acb/metrics" @@ -15,6 +17,18 @@ import ( const valkeyJobQueue = "acb:jobs:pending" +// candidateBot holds bot data used during the §6.1 matchmaking algorithm. +type candidateBot struct { + ID string + Endpoint string + Secret string + Mu float64 + Phi float64 + LastMatchAt time.Time + Games24h int + LastPairedAt time.Time // zero = never paired with the seed bot +} + func (m *Matchmaker) StartTickers(ctx context.Context) { go m.runTicker(ctx, "matchmaker", time.Duration(m.cfg.MatchmakerSecs)*time.Second, m.tickMatchmaker) go m.runTicker(ctx, "health-checker", time.Duration(m.cfg.HealthCheckSecs)*time.Second, m.tickHealthChecker) @@ -38,74 +52,343 @@ func (m *Matchmaker) runTicker(ctx context.Context, name string, interval time.D } } -// tickMatchmaker creates matches between active bots and enqueues jobs. +// tickMatchmaker implements the §6.1 pairing algorithm: +// 1. Seed = bot with longest time since last match (tiebreak: lowest bot ID) +// 2. Format = seed's least-played player count among {2, 3, 4, 6} +// 3. Opponents = Pareto skill-proximity (80% within 16 ranks) + oldest last-pairing + fewest 24h games +// 4. Map = least-recently-used active map for the chosen player count +// 5. Enqueue match job with randomised player slot assignment func (m *Matchmaker) tickMatchmaker(ctx context.Context) { - // Get all active bots not on crash cooldown (§4.5, §6.1) - rows, err := m.db.QueryContext(ctx, - `SELECT bot_id, endpoint_url, shared_secret, rating_mu, rating_phi - FROM bots WHERE status = 'active' - AND (cooldown_until IS NULL OR cooldown_until < NOW()) - ORDER BY rating_mu DESC`) + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + + // Step 1: load all eligible bots with last-match time and 24h game count. + candidates, err := m.queryEligibleCandidates(ctx) if err != nil { - log.Printf("matchmaker: query error: %v", err) + log.Printf("matchmaker: query candidates: %v", err) + return + } + if len(candidates) < 2 { return } - type botInfo struct { - ID string - Endpoint string - Secret string - Mu, Phi float64 + // Step 2: seed = bot with oldest last-match timestamp (tiebreak: lowest bot ID). + seed := candidates[0] + for _, c := range candidates[1:] { + if c.LastMatchAt.Before(seed.LastMatchAt) || + (c.LastMatchAt.Equal(seed.LastMatchAt) && c.ID < seed.ID) { + seed = c + } } - var bots []botInfo - for rows.Next() { - var b botInfo - if err := rows.Scan(&b.ID, &b.Endpoint, &b.Secret, &b.Mu, &b.Phi); err != nil { - rows.Close() - log.Printf("matchmaker: scan error: %v", err) + + // Step 3: format = seed's least-played player count, feasible given active bot count. + matchSize, err := m.leastPlayedFormat(ctx, seed.ID, len(candidates)) + if err != nil { + log.Printf("matchmaker: format select: %v", err) + matchSize = 2 + } + + // Step 4: annotate pool with last-pairing recency relative to seed. + pairTimes, err := m.queryPairingTimes(ctx, seed.ID) + if err != nil { + log.Printf("matchmaker: pairing times: %v", err) + pairTimes = map[string]time.Time{} + } + pool := make([]candidateBot, 0, len(candidates)-1) + for _, c := range candidates { + if c.ID == seed.ID { + continue + } + c.LastPairedAt = pairTimes[c.ID] + pool = append(pool, c) + } + + // Step 5: select opponents with Pareto + recency + game-balance criteria. + opponents := selectOpponents(rng, seed.Mu, pool, matchSize-1) + if len(opponents) < matchSize-1 { + // Not enough bots for the desired format — fall back to 2-player. + matchSize = 2 + opponents = selectOpponents(rng, seed.Mu, pool, 1) + if len(opponents) == 0 { return } - bots = append(bots, b) } - rows.Close() - if len(bots) < 2 { + // Step 6: LRU map selection for this player count. + mapID, mapRows, mapCols, mapSeed := m.selectMapLRU(ctx, matchSize, rng) + + // Step 7: create match DB records and enqueue job. + participants := append([]candidateBot{seed}, opponents...) + if err := m.createMatch(ctx, rng, participants, mapID, mapRows, mapCols, mapSeed, matchSize); err != nil { + log.Printf("matchmaker: create match: %v", err) return } - // Create one match per tick: pick two bots at random (with rating-aware weighting later) - rng := rand.New(rand.NewSource(time.Now().UnixNano())) - i := rng.Intn(len(bots)) - j := rng.Intn(len(bots) - 1) - if j >= i { - j++ + // Update map_scores.last_used_at (best-effort, outside the transaction). + m.db.ExecContext(ctx, ` + INSERT INTO map_scores (map_id, last_used_at, match_count) + VALUES ($1, NOW(), 1) + ON CONFLICT (map_id) DO UPDATE + SET last_used_at = NOW(), match_count = map_scores.match_count + 1 + `, mapID) +} + +// queryEligibleCandidates returns active bots not on crash cooldown (§4.5, §6.1), +// annotated with their last-match timestamp and 24-hour game count. +func (m *Matchmaker) queryEligibleCandidates(ctx context.Context) ([]candidateBot, error) { + rows, err := m.db.QueryContext(ctx, ` + SELECT + b.bot_id, + b.endpoint_url, + b.shared_secret, + b.rating_mu, + b.rating_phi, + COALESCE(lm.last_match_at, '1970-01-01 00:00:00+00'::timestamptz) AS last_match_at, + COALESCE(g.games_24h, 0) AS games_24h + FROM bots b + LEFT JOIN ( + SELECT mp.bot_id, MAX(m.created_at) AS last_match_at + FROM match_participants mp + JOIN matches m ON mp.match_id = m.match_id + GROUP BY mp.bot_id + ) lm ON lm.bot_id = b.bot_id + LEFT JOIN ( + SELECT mp.bot_id, COUNT(*)::int AS games_24h + FROM match_participants mp + JOIN matches m ON mp.match_id = m.match_id + WHERE m.created_at >= NOW() - INTERVAL '24 hours' + GROUP BY mp.bot_id + ) g ON g.bot_id = b.bot_id + WHERE b.status = 'active' + AND (b.cooldown_until IS NULL OR b.cooldown_until < NOW()) + `) + if err != nil { + return nil, err } + defer rows.Close() - botA := bots[i] - botB := bots[j] + var out []candidateBot + for rows.Next() { + var c candidateBot + if err := rows.Scan(&c.ID, &c.Endpoint, &c.Secret, &c.Mu, &c.Phi, &c.LastMatchAt, &c.Games24h); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} +// leastPlayedFormat returns the player count (2/3/4/6) that seedID has participated +// in fewest times. Skips formats that require more bots than numCandidates. +func (m *Matchmaker) leastPlayedFormat(ctx context.Context, seedID string, numCandidates int) (int, error) { + rows, err := m.db.QueryContext(ctx, ` + WITH seed_sizes AS ( + SELECT COUNT(mp2.bot_id)::int AS player_count + FROM match_participants mp1 + JOIN matches mx ON mx.match_id = mp1.match_id + JOIN match_participants mp2 ON mp2.match_id = mx.match_id + WHERE mp1.bot_id = $1 + GROUP BY mx.match_id + ), + format_counts AS ( + SELECT player_count, COUNT(*) AS cnt + FROM seed_sizes + GROUP BY player_count + ) + SELECT f.n, COALESCE(fc.cnt, 0) AS cnt + FROM (VALUES (2), (3), (4), (6)) f(n) + LEFT JOIN format_counts fc ON fc.player_count = f.n + ORDER BY cnt ASC, f.n ASC + `, seedID) + if err != nil { + return 2, err + } + defer rows.Close() + + for rows.Next() { + var n, cnt int + if err := rows.Scan(&n, &cnt); err != nil { + return 2, err + } + if numCandidates >= n { + return n, nil + } + } + return 2, rows.Err() +} + +// queryPairingTimes returns a map of bot_id → most recent time it shared a match +// with seedID. Bots that have never been paired with seedID are absent from the map. +func (m *Matchmaker) queryPairingTimes(ctx context.Context, seedID string) (map[string]time.Time, error) { + rows, err := m.db.QueryContext(ctx, ` + SELECT mp2.bot_id, MAX(mx.created_at) AS last_paired_at + FROM match_participants mp1 + JOIN matches mx ON mx.match_id = mp1.match_id + JOIN match_participants mp2 + ON mp2.match_id = mx.match_id AND mp2.bot_id != $1 + WHERE mp1.bot_id = $1 + GROUP BY mp2.bot_id + `, seedID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make(map[string]time.Time) + for rows.Next() { + var botID string + var t time.Time + if err := rows.Scan(&botID, &t); err != nil { + return nil, err + } + out[botID] = t + } + return out, rows.Err() +} + +// selectOpponents picks `count` opponents from pool using §6.1 criteria: +// - Pareto: 80% chance restrict selection to the 16 rating-closest bots +// - Secondary: oldest last-pairing with seed (zero = never = most preferred) +// - Tertiary: fewest 24h games breaks remaining ties +func selectOpponents(rng *rand.Rand, seedMu float64, pool []candidateBot, count int) []candidateBot { + remaining := make([]candidateBot, len(pool)) + copy(remaining, pool) + + selected := make([]candidateBot, 0, count) + for i := 0; i < count && len(remaining) > 0; i++ { + // Sort by rating proximity to seed. + sort.Slice(remaining, func(a, b int) bool { + return math.Abs(remaining[a].Mu-seedMu) < math.Abs(remaining[b].Mu-seedMu) + }) + + // Pareto: 80% from the 16 closest, 20% from all. + eligible := remaining + if rng.Float64() < 0.80 { + n := 16 + if n > len(remaining) { + n = len(remaining) + } + eligible = remaining[:n] + } + + best := bestCandidate(eligible) + selected = append(selected, best) + + for j, c := range remaining { + if c.ID == best.ID { + remaining = append(remaining[:j], remaining[j+1:]...) + break + } + } + } + return selected +} + +// bestCandidate picks the best opponent from a pool by secondary criteria: +// oldest last-pairing (zero = never = most preferred), then fewest 24h games. +func bestCandidate(pool []candidateBot) candidateBot { + best := pool[0] + for _, c := range pool[1:] { + bz := best.LastPairedAt.IsZero() + cz := c.LastPairedAt.IsZero() + switch { + case cz && !bz: + best = c + case !cz && !bz && c.LastPairedAt.Before(best.LastPairedAt): + best = c + case bz == cz && c.Games24h < best.Games24h: + best = c + } + } + return best +} + +// selectMapLRU returns the active map for playerCount with the oldest last_used_at. +// Falls back to a random procedural seed if no maps exist for that player count. +func (m *Matchmaker) selectMapLRU(ctx context.Context, playerCount int, rng *rand.Rand) (string, int, int, int64) { + var mapID string + var gridH, gridW int + err := m.db.QueryRowContext(ctx, ` + SELECT mp.map_id, mp.grid_height, mp.grid_width + FROM maps mp + LEFT JOIN map_scores ms ON ms.map_id = mp.map_id + WHERE mp.player_count = $1 AND mp.status = 'active' + ORDER BY COALESCE(ms.last_used_at, '1970-01-01 00:00:00+00'::timestamptz) ASC + LIMIT 1 + `, playerCount).Scan(&mapID, &gridH, &gridW) + if err != nil { + seed := rng.Int63() + rows, cols := gridForPlayers(playerCount) + return fmt.Sprintf("map_%d", seed%100000), rows, cols, seed + } + return mapID, gridH, gridW, rng.Int63() +} + +// gridForPlayers returns default grid dimensions for a given player count, +// mirroring the formula in engine.ConfigForPlayers (~2000 tiles per player). +func gridForPlayers(n int) (rows, cols int) { + if n <= 2 { + return 60, 60 + } + side := int(math.Sqrt(float64(2000 * n))) + if side < 40 { + side = 40 + } + if side > 200 { + side = 200 + } + return side, side +} + +// createMatch inserts match, participants, and job rows, then enqueues in Valkey. +func (m *Matchmaker) createMatch( + ctx context.Context, + rng *rand.Rand, + participants []candidateBot, + mapID string, + mapRows, mapCols int, + mapSeed int64, + playerCount int, +) error { matchID, err := generateID("m_", 8) if err != nil { - log.Printf("matchmaker: generate match ID error: %v", err) - return + return err } - jobID, err := generateID("j_", 8) if err != nil { - log.Printf("matchmaker: generate job ID error: %v", err) - return + return err } - mapSeed := rng.Int63() - mapID := fmt.Sprintf("map_%d", mapSeed%100000) + // Compute max turns from grid size; floor at 500. + maxTurns := mapRows * 8 + if maxTurns < 500 { + maxTurns = 500 + } + + // Randomise player slots. + slots := rng.Perm(len(participants)) - // Build job config type botConfig struct { BotID string `json:"bot_id"` Endpoint string `json:"endpoint"` Secret string `json:"secret"` Slot int `json:"slot"` } + botCfgs := make([]botConfig, len(participants)) + for i, p := range participants { + secret := p.Secret + if m.cfg.EncryptionKey != "" { + if dec, decErr := decryptSecret(p.Secret, m.cfg.EncryptionKey); decErr == nil { + secret = dec + } + } + botCfgs[i] = botConfig{ + BotID: p.ID, + Endpoint: p.Endpoint, + Secret: secret, + Slot: slots[i], + } + } + type jobConfig struct { MatchID string `json:"match_id"` MapSeed int64 `json:"map_seed"` @@ -114,79 +397,66 @@ func (m *Matchmaker) tickMatchmaker(ctx context.Context) { Cols int `json:"cols"` Bots []botConfig `json:"bots"` } - - // Decrypt secrets for the worker - secretA := botA.Secret - secretB := botB.Secret - if m.cfg.EncryptionKey != "" { - if dec, err := decryptSecret(botA.Secret, m.cfg.EncryptionKey); err == nil { - secretA = dec - } - if dec, err := decryptSecret(botB.Secret, m.cfg.EncryptionKey); err == nil { - secretB = dec - } - } - - config := jobConfig{ + cfg := jobConfig{ MatchID: matchID, MapSeed: mapSeed, - MaxTurns: 500, - Rows: 60, - Cols: 60, - Bots: []botConfig{ - {BotID: botA.ID, Endpoint: botA.Endpoint, Secret: secretA, Slot: 0}, - {BotID: botB.ID, Endpoint: botB.Endpoint, Secret: secretB, Slot: 1}, - }, + MaxTurns: maxTurns, + Rows: mapRows, + Cols: mapCols, + Bots: botCfgs, } - configJSON, _ := json.Marshal(config) + configJSON, _ := json.Marshal(cfg) tx, err := m.db.BeginTx(ctx, nil) if err != nil { - log.Printf("matchmaker: tx error: %v", err) - return + return err } defer tx.Rollback() - _, err = tx.ExecContext(ctx, + if _, err := tx.ExecContext(ctx, `INSERT INTO matches (match_id, map_id, map_seed, status) VALUES ($1, $2, $3, 'pending')`, - matchID, mapID, mapSeed) - if err != nil { - log.Printf("matchmaker: insert match error: %v", err) - return + matchID, mapID, mapSeed); err != nil { + return fmt.Errorf("insert match: %w", err) } - _, err = tx.ExecContext(ctx, - `INSERT INTO match_participants (match_id, bot_id, player_slot) VALUES ($1, $2, 0), ($1, $3, 1)`, - matchID, botA.ID, botB.ID) - if err != nil { - log.Printf("matchmaker: insert participants error: %v", err) - return + // Build multi-row INSERT for participants. + clauses := make([]string, len(participants)) + args := make([]interface{}, 0, 1+2*len(participants)) + args = append(args, matchID) + for i, p := range participants { + clauses[i] = fmt.Sprintf("($1, $%d, $%d)", 2+2*i, 3+2*i) + args = append(args, p.ID, slots[i]) + } + if _, err := tx.ExecContext(ctx, + "INSERT INTO match_participants (match_id, bot_id, player_slot) VALUES "+strings.Join(clauses, ", "), + args...); err != nil { + return fmt.Errorf("insert participants: %w", err) } - _, err = tx.ExecContext(ctx, + if _, err := tx.ExecContext(ctx, `INSERT INTO jobs (job_id, match_id, status, config_json) VALUES ($1, $2, 'pending', $3)`, - jobID, matchID, configJSON) - if err != nil { - log.Printf("matchmaker: insert job error: %v", err) - return + jobID, matchID, configJSON); err != nil { + return fmt.Errorf("insert job: %w", err) } if err := tx.Commit(); err != nil { - log.Printf("matchmaker: commit error: %v", err) - return + return err } - // Enqueue in Valkey if err := m.rdb.LPush(ctx, valkeyJobQueue, jobID).Err(); err != nil { - log.Printf("matchmaker: valkey push error: %v", err) - return + return fmt.Errorf("valkey push: %w", err) } - // Update metrics depth, _ := m.rdb.LLen(ctx, valkeyJobQueue).Result() metrics.JobQueueDepth.Set(float64(depth)) - log.Printf("matchmaker: created match %s (%s vs %s), job %s", matchID, botA.ID, botB.ID, jobID) + opIDs := make([]string, len(participants)-1) + for i, p := range participants[1:] { + opIDs[i] = p.ID + } + log.Printf("matchmaker: created %d-player match %s (seed=%s vs %v), job %s, map=%s", + playerCount, matchID, participants[0].ID, opIDs, jobID, mapID) + return nil } // tickHealthChecker pings each active bot's /health endpoint. @@ -319,6 +589,3 @@ func (m *Matchmaker) queryActiveBotCount(ctx context.Context) (int, error) { err := m.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM bots WHERE status = 'active'`).Scan(&count) return count, err } - -// Unused but required to avoid "imported and not used" for sql package -var _ = sql.ErrNoRows diff --git a/cmd/acb-worker/db.go b/cmd/acb-worker/db.go index 05def02..fb80fad 100644 --- a/cmd/acb-worker/db.go +++ b/cmd/acb-worker/db.go @@ -57,12 +57,14 @@ type DBJob struct { // DBMatch represents match metadata from the database. type DBMatch struct { - ID string `json:"id"` - Status string `json:"status"` - Winner *int `json:"winner"` // player index - MapID string `json:"map_id"` - CreatedAt time.Time `json:"created_at"` - CompletedAt *time.Time `json:"completed_at"` + ID string `json:"id"` + Status string `json:"status"` + Winner *int `json:"winner"` // player index + MapID string `json:"map_id"` + CreatedAt time.Time `json:"created_at"` + CompletedAt *time.Time `json:"completed_at"` + SeasonID string `json:"season_id"` + RulesVersion string `json:"rules_version"` } // DBParticipant represents a match participant. @@ -163,14 +165,18 @@ func (c *DBClient) ClaimJob(ctx context.Context, jobID string, workerID string) return nil, fmt.Errorf("failed to get job: %w", err) } - // Get match details + // Get match details + active season info var match DBMatch err = tx.QueryRowContext(ctx, ` - SELECT match_id, status, winner, map_id, created_at, completed_at - FROM matches WHERE match_id = $1 + SELECT m.match_id, m.status, m.winner, m.map_id, m.created_at, m.completed_at, + COALESCE(s.season_id, ''), COALESCE(s.rules_version::text, '') + FROM matches m + LEFT JOIN seasons s ON s.status = 'active' + WHERE m.match_id = $1 `, job.MatchID).Scan( &match.ID, &match.Status, &match.Winner, &match.MapID, &match.CreatedAt, &match.CompletedAt, + &match.SeasonID, &match.RulesVersion, ) if err != nil { return nil, fmt.Errorf("failed to get match: %w", err) diff --git a/cmd/acb-worker/main.go b/cmd/acb-worker/main.go index 394ddcc..eab2be2 100644 --- a/cmd/acb-worker/main.go +++ b/cmd/acb-worker/main.go @@ -21,6 +21,7 @@ import ( "github.com/aicodebattle/acb/engine" "github.com/aicodebattle/acb/metrics" + "image/png" ) // Config holds worker configuration. type Config struct { @@ -226,6 +227,15 @@ func (w *Worker) pollAndExecute(ctx context.Context) error { metrics.ReplayUploadLatency.Observe(uploadSec) w.logger.Printf("Uploaded replay to %s", replayURL) } + + // Generate and upload thumbnail + thumbStart := time.Now() + if thumbErr := w.uploadThumbnail(ctx, claimData.Match.ID, replay); thumbErr != nil { + w.logger.Printf("Failed to upload thumbnail: %v", thumbErr) + } else { + thumbSec := time.Since(thumbStart).Seconds() + w.logger.Printf("Uploaded thumbnail in %.2fs", thumbSec) + } } // Compute Glicko-2 rating updates @@ -253,6 +263,8 @@ func (w *Worker) executeMatch(ctx context.Context, claimData *JobClaimData) (*Ma AttackRadius2: 5, // Default attack SpawnCost: 3, // Default spawn cost EnergyInterval: 10, // Default energy interval + SeasonID: claimData.Match.SeasonID, + RulesVersion: claimData.Match.RulesVersion, } // Create match runner @@ -395,6 +407,33 @@ func (w *Worker) uploadReplay(ctx context.Context, matchID string, replay *engin return fmt.Sprintf("%s/%s", w.b2.Endpoint(), key), nil } +// uploadThumbnail generates and uploads a PNG thumbnail for the match. +func (w *Worker) uploadThumbnail(ctx context.Context, matchID string, replay *engine.Replay) error { + if w.b2 == nil { + return fmt.Errorf("B2 client not configured") + } + + // Generate thumbnail image + img, err := engine.GenerateMatchThumbnail(replay) + if err != nil { + return fmt.Errorf("failed to generate thumbnail: %w", err) + } + + // Encode as PNG + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return fmt.Errorf("failed to encode thumbnail as PNG: %w", err) + } + + // Upload to B2 + key := fmt.Sprintf("thumbnails/%s.png", matchID) + if err := w.b2.Upload(ctx, key, buf.Bytes(), "image/png", ""); err != nil { + return fmt.Errorf("failed to upload thumbnail to B2: %w", err) + } + + return nil +} + // computeRatingUpdates computes Glicko-2 rating updates for match participants. func (w *Worker) computeRatingUpdates(claimData *JobClaimData, result *MatchResult) []RatingUpdate { if len(claimData.Participants) < 2 { diff --git a/engine/types.go b/engine/types.go index 769645a..447ef0d 100644 --- a/engine/types.go +++ b/engine/types.go @@ -135,14 +135,16 @@ type Move struct { // Config holds game configuration parameters. type Config struct { - Rows int `json:"rows"` - Cols int `json:"cols"` - MaxTurns int `json:"max_turns"` - VisionRadius2 int `json:"vision_radius2"` // squared vision distance - AttackRadius2 int `json:"attack_radius2"` // squared attack distance - SpawnCost int `json:"spawn_cost"` // energy cost to spawn a bot - EnergyInterval int `json:"energy_interval"` // turns between energy spawns - CoresPerPlayer int `json:"cores_per_player"` // starting cores per player + Rows int `json:"rows"` + Cols int `json:"cols"` + MaxTurns int `json:"max_turns"` + VisionRadius2 int `json:"vision_radius2"` // squared vision distance + AttackRadius2 int `json:"attack_radius2"` // squared attack distance + SpawnCost int `json:"spawn_cost"` // energy cost to spawn a bot + EnergyInterval int `json:"energy_interval"` // turns between energy spawns + CoresPerPlayer int `json:"cores_per_player"` // starting cores per player + SeasonID string `json:"season_id,omitempty"` + RulesVersion string `json:"rules_version,omitempty"` } // DefaultConfig returns the default game configuration. diff --git a/starters/csharp/Program.cs b/starters/csharp/Program.cs index b68759e..af5239f 100644 --- a/starters/csharp/Program.cs +++ b/starters/csharp/Program.cs @@ -52,6 +52,13 @@ app.MapPost("/turn", (HttpContext ctx) => return Results.BadRequest("Invalid JSON"); } + if (state.Turn == 0) + { + var seasonId = state.Config.SeasonId ?? ""; + var rulesVersion = state.Config.RulesVersion ?? ""; + Console.WriteLine($"match={state.MatchId} season_id={seasonId} rules_version={rulesVersion} rows={state.Config.Rows} cols={state.Config.Cols}"); + } + var moves = ComputeMoves(state); var responseBody = JsonSerializer.Serialize(new { moves }); var turn = int.Parse(turnStr); @@ -182,6 +189,8 @@ record GameConfig public int AttackRadius2 { get; init; } public int SpawnCost { get; init; } public int EnergyInterval { get; init; } + public string? SeasonId { get; init; } + public string? RulesVersion { get; init; } } record You diff --git a/starters/go/main.go b/starters/go/main.go index cee2824..1859da4 100644 --- a/starters/go/main.go +++ b/starters/go/main.go @@ -23,13 +23,15 @@ var directions = []string{"N", "E", "S", "W"} // GameConfig holds the match configuration. type GameConfig struct { - Rows int `json:"rows"` - Cols int `json:"cols"` - MaxTurns int `json:"max_turns"` - VisionRadius2 int `json:"vision_radius2"` - AttackRadius2 int `json:"attack_radius2"` - SpawnCost int `json:"spawn_cost"` - EnergyInterval int `json:"energy_interval"` + Rows int `json:"rows"` + Cols int `json:"cols"` + MaxTurns int `json:"max_turns"` + VisionRadius2 int `json:"vision_radius2"` + AttackRadius2 int `json:"attack_radius2"` + SpawnCost int `json:"spawn_cost"` + EnergyInterval int `json:"energy_interval"` + SeasonID string `json:"season_id,omitempty"` + RulesVersion string `json:"rules_version,omitempty"` } // Position is a grid coordinate. @@ -128,6 +130,12 @@ func handleTurn(w http.ResponseWriter, r *http.Request, secret string) { return } + if state.Turn == 0 { + log.Printf("match=%s season_id=%s rules_version=%s rows=%d cols=%d", + state.MatchID, state.Config.SeasonID, state.Config.RulesVersion, + state.Config.Rows, state.Config.Cols) + } + moves := computeMoves(&state) response := MoveResponse{Moves: moves} responseBody, _ := json.Marshal(response) diff --git a/starters/java/src/main/java/com/acb/starter/App.java b/starters/java/src/main/java/com/acb/starter/App.java index 2ef8435..f5d00ea 100644 --- a/starters/java/src/main/java/com/acb/starter/App.java +++ b/starters/java/src/main/java/com/acb/starter/App.java @@ -65,6 +65,14 @@ public class App { try { GameState state = MAPPER.readValue(body, GameState.class); + + if (state.turn == 0) { + String seasonId = state.config.season_id != null ? state.config.season_id : ""; + String rulesVersion = state.config.rules_version != null ? state.config.rules_version : ""; + System.out.printf("match=%s season_id=%s rules_version=%s rows=%d cols=%d%n", + state.match_id, seasonId, rulesVersion, state.config.rows, state.config.cols); + } + List moves = computeMoves(state); String responseBody = MAPPER.writeValueAsString(new MoveResponse(moves)); @@ -171,7 +179,8 @@ public class App { // --- Data classes --- public record GameConfig(int rows, int cols, int max_turns, int vision_radius2, - int attack_radius2, int spawn_cost, int energy_interval) {} + int attack_radius2, int spawn_cost, int energy_interval, + String season_id, String rules_version) {} public record You(int id, int energy, int score) {} diff --git a/starters/python/main.py b/starters/python/main.py index ea5d97a..17c2e64 100644 --- a/starters/python/main.py +++ b/starters/python/main.py @@ -91,6 +91,11 @@ class BotHandler(BaseHTTPRequestHandler): self.send_error(400, f"Invalid game state: {e}") return + if state.turn == 0: + season_id = state.config.get("season_id", "") + rules_version = state.config.get("rules_version", "") + print(f"match={state.match_id} season_id={season_id} rules_version={rules_version} rows={state.config['rows']} cols={state.config['cols']}") + moves = compute_moves(state) turn = int(turn_str) diff --git a/starters/rust/src/main.rs b/starters/rust/src/main.rs index d4d46fa..469106b 100644 --- a/starters/rust/src/main.rs +++ b/starters/rust/src/main.rs @@ -44,6 +44,10 @@ struct GameConfig { attack_radius2: u32, spawn_cost: u32, energy_interval: u32, + #[serde(default)] + season_id: Option, + #[serde(default)] + rules_version: Option, } #[derive(Deserialize)] @@ -147,6 +151,16 @@ async fn handle_turn( let game_state: GameState = serde_json::from_slice(&body).map_err(|_| StatusCode::BAD_REQUEST)?; + if game_state.turn == 0 { + let season_id = game_state.config.season_id.as_deref().unwrap_or(""); + let rules_version = game_state.config.rules_version.as_deref().unwrap_or(""); + println!( + "match={} season_id={} rules_version={} rows={} cols={}", + game_state.match_id, season_id, rules_version, + game_state.config.rows, game_state.config.cols + ); + } + let moves = compute_moves(&game_state); let response = MoveResponse { moves }; let response_body = serde_json::to_string(&response).unwrap(); diff --git a/web/src/components/annotation.ts b/web/src/components/annotation.ts index eb32472..d83c726 100644 --- a/web/src/components/annotation.ts +++ b/web/src/components/annotation.ts @@ -51,18 +51,56 @@ export function loadLocalAnnotations(matchId?: string): Annotation[] { } } -// ─── Fetch feedback.json from pre-built data ──────────────────────────────── +// ─── Fetch feedback from API ───────────────────────────────────────────────── export async function fetchFeedback(matchId: string): Promise { try { - const resp = await fetch(`/data/matches/${matchId}/feedback.json`); + const resp = await fetch(`${API_BASE}/feedback/${matchId}`); if (!resp.ok) return []; - return resp.json(); + const data = await resp.json(); + // API returns { match_id, feedback: FeedbackEntry[] } — map to Annotation + if (!data.feedback || !Array.isArray(data.feedback)) return []; + return data.feedback.map((f: { feedback_id: string; match_id: string; turn: number; type: FeedbackType; body: string; author: string; upvotes: number; created_at: string }) => ({ + id: f.feedback_id, + match_id: f.match_id, + turn: f.turn, + type: f.type, + body: f.body, + author: f.author, + upvotes: f.upvotes, + created_at: f.created_at, + })); } catch { return []; } } +// ─── Upvote (POST to API) ──────────────────────────────────────────────────── + +const VISITOR_KEY = 'acb_visitor_id'; + +function getVisitorId(): string { + let id = localStorage.getItem(VISITOR_KEY); + if (!id) { + id = crypto.randomUUID(); + localStorage.setItem(VISITOR_KEY, id); + } + return id; +} + +export async function upvoteFeedback(feedbackId: string): Promise { + try { + const resp = await fetch(`${API_BASE}/feedback/${feedbackId}/upvote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ voter_id: getVisitorId() }), + }); + return resp.ok; + } catch { + return false; + } +} + // ─── Submit (POST to API, localStorage fallback) ──────────────────────────── const API_BASE = '/api'; @@ -200,7 +238,7 @@ export class AnnotationOverlay {
${escapeHtml(ann.body)}
${pos ? `
@ ${pos}
` : ''}
- ${ann.upvotes} upvotes + ${formatTime(ann.created_at)}
`; diff --git a/web/src/types.ts b/web/src/types.ts index ff073be..f5ad56c 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -13,6 +13,8 @@ export interface Config { attack_radius2: number; spawn_cost: number; energy_interval: number; + season_id?: string; + rules_version?: string; } export interface MatchResult { @@ -280,6 +282,26 @@ export interface PredictionLeaderboard { leaders: PredictorStats[]; } +// Community replay feedback (plan §13.6, §8.3) + +export type FeedbackType = 'insight' | 'mistake' | 'idea' | 'highlight'; + +export interface FeedbackEntry { + feedback_id: string; + match_id: string; + turn: number; + type: FeedbackType; + body: string; + author: string; + upvotes: number; + created_at: string; +} + +export interface FeedbackResponse { + match_id: string; + feedback: FeedbackEntry[]; +} + // Evolution live.json schema (plan §14) — real-time dashboard feed from acb-evolver export interface EvolutionIslandStat {