feat(matchmaker): implement §6.1 Pareto skill-proximity + LRU pairing algorithm

Replace random 2-player pairing with the full §6.1 algorithm:
- Seed selection: bot with oldest last-match timestamp (tiebreak: lowest bot ID)
- Format selection: seed's least-played player count among {2, 3, 4, 6}
- Opponent selection: Pareto 80%/16-rank skill proximity + oldest last-pairing
  with seed + fewest 24h games for game-count balance
- Map selection: least-recently-used active map for the chosen player count,
  with map_scores.last_used_at updated after each match
- Random player slot assignment for all participant counts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 17:35:00 -04:00
parent 4dd91decad
commit 477a54c548
18 changed files with 1310 additions and 262 deletions

View file

@ -1 +1 @@
da824f736002b1e597d4c5c658c1122a1f3895b4
4dd91decad4538636d34df90d01199a74a7e1392

View file

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

View file

@ -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, &currentStatus)
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

View file

@ -6,14 +6,17 @@
// Promotion flow
//
// 1. Generate a unique bot name (acb-evo-<programID>), bot ID, and secret.
// 2. Write bot source + language-appropriate Dockerfile to bots/evolved/<name>/.
// 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/<name>/.
// 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 ──────────────────────────────────────────────────────

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -44,6 +44,10 @@ struct GameConfig {
attack_radius2: u32,
spawn_cost: u32,
energy_interval: u32,
#[serde(default)]
season_id: Option<String>,
#[serde(default)]
rules_version: Option<String>,
}
#[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();

View file

@ -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<Annotation[]> {
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<boolean> {
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 {
<div class="ann-item-body">${escapeHtml(ann.body)}</div>
${pos ? `<div class="ann-item-pos">@ ${pos}</div>` : ''}
<div class="ann-item-meta">
<span class="ann-item-upvotes">${ann.upvotes} upvotes</span>
<button class="ann-upvote-btn" data-feedback-id="${ann.id}" title="Upvote">&#9650; ${ann.upvotes}</button>
<span class="ann-item-time">${formatTime(ann.created_at)}</span>
</div>
</div>`;

View file

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