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:
parent
4dd91decad
commit
477a54c548
18 changed files with 1310 additions and 262 deletions
|
|
@ -1 +1 @@
|
|||
da824f736002b1e597d4c5c658c1122a1f3895b4
|
||||
4dd91decad4538636d34df90d01199a74a7e1392
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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">▲ ${ann.upvotes}</button>
|
||||
<span class="ann-item-time">${formatTime(ann.created_at)}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue