From 9bd4efc9357cbbd07b508a85636b5f0dc3ea955f Mon Sep 17 00:00:00 2001 From: jedarden Date: Tue, 24 Mar 2026 07:50:10 -0400 Subject: [PATCH] Start Phase 4: Cloudflare Worker API for match orchestration - Add worker-api/ with TypeScript + Wrangler setup - D1 database schema (bots, matches, jobs, rating_history) - Glicko-2 rating system implementation with unit tests - Job coordination endpoints (claim, heartbeat, result, fail) - Bot management endpoints (register, list, update, rotate-key) - Cron handlers (matchmaker, health checker, stale job reaper) Co-Authored-By: Claude Opus 4.6 --- PROGRESS.md | 36 +++- worker-api/package.json | 17 ++ worker-api/schema.sql | 112 ++++++++++++ worker-api/src/bots.ts | 240 +++++++++++++++++++++++++ worker-api/src/cron.ts | 228 ++++++++++++++++++++++++ worker-api/src/glicko2.test.ts | 292 +++++++++++++++++++++++++++++++ worker-api/src/glicko2.ts | 309 +++++++++++++++++++++++++++++++++ worker-api/src/index.ts | 203 ++++++++++++++++++++++ worker-api/src/jobs.ts | 244 ++++++++++++++++++++++++++ worker-api/src/types.ts | 122 +++++++++++++ worker-api/tsconfig.json | 20 +++ worker-api/vitest.config.ts | 9 + worker-api/wrangler.toml | 23 +++ 13 files changed, 1853 insertions(+), 2 deletions(-) create mode 100644 worker-api/package.json create mode 100644 worker-api/schema.sql create mode 100644 worker-api/src/bots.ts create mode 100644 worker-api/src/cron.ts create mode 100644 worker-api/src/glicko2.test.ts create mode 100644 worker-api/src/glicko2.ts create mode 100644 worker-api/src/index.ts create mode 100644 worker-api/src/jobs.ts create mode 100644 worker-api/src/types.ts create mode 100644 worker-api/tsconfig.json create mode 100644 worker-api/vitest.config.ts create mode 100644 worker-api/wrangler.toml diff --git a/PROGRESS.md b/PROGRESS.md index 0099cce..183d289 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,8 +1,40 @@ # AI Code Battle - Implementation Progress -## Current Phase: Phase 3 - Replay Viewer +## Current Phase: Phase 4 - Match Orchestration -**Status: ✅ COMPLETE** +**Status: 🔄 In Progress** + +### Phase 4 Progress + +- [x] Cloudflare Worker project structure (`worker-api/`) + - TypeScript + Wrangler configuration + - D1 database schema (bots, matches, jobs, rating_history tables) +- [x] Glicko-2 rating system (`worker-api/src/glicko2.ts`) + - Rating scale conversion + - Rating updates after matches + - Rating decay for inactive bots + - Unit tests (17 tests) +- [x] Job coordination endpoints (`worker-api/src/jobs.ts`) + - GET /api/jobs/next - Get next pending job + - POST /api/jobs/:id/claim - Claim job for execution + - POST /api/jobs/:id/heartbeat - Update job heartbeat + - POST /api/jobs/:id/result - Submit match result + - POST /api/jobs/:id/fail - Mark job as failed +- [x] Bot management endpoints (`worker-api/src/bots.ts`) + - POST /api/register - Register new bot + - GET /api/bots - List all bots + - GET /api/bots/:id - Get bot details + - PUT /api/bots/:id - Update bot + - POST /api/rotate-key - Rotate API key + - GET /api/leaderboard - Get leaderboard +- [x] Cron handlers (`worker-api/src/cron.ts`) + - Matchmaker (every minute) - Creates match jobs + - Health checker (every 15 min) - Pings bot endpoints + - Stale job reaper (every 5 min) - Reclaims timed-out jobs +- [ ] Match worker container (`cmd/acb-worker/`) +- [ ] Rackspace index builder + +### Phase 3 Completed ### Phase 1 Completed diff --git a/worker-api/package.json b/worker-api/package.json new file mode 100644 index 0000000..8320a45 --- /dev/null +++ b/worker-api/package.json @@ -0,0 +1,17 @@ +{ + "name": "acb-api", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "cf-typegen": "wrangler types", + "test": "vitest" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250310.0", + "typescript": "^5.8.2", + "vitest": "^3.0.9", + "wrangler": "^4.4.0" + } +} diff --git a/worker-api/schema.sql b/worker-api/schema.sql new file mode 100644 index 0000000..d8506c0 --- /dev/null +++ b/worker-api/schema.sql @@ -0,0 +1,112 @@ +-- AI Code Battle D1 Schema +-- Phase 4: Match Orchestration + +-- Bots table: stores registered bots +CREATE TABLE IF NOT EXISTS bots ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + owner_id TEXT NOT NULL, + endpoint_url TEXT NOT NULL, + api_key_hash TEXT NOT NULL, + rating REAL NOT NULL DEFAULT 1500.0, + rating_deviation REAL NOT NULL DEFAULT 350.0, + rating_volatility REAL NOT NULL DEFAULT 0.06, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + last_health_check TEXT, + health_status TEXT DEFAULT 'unknown', + matches_played INTEGER NOT NULL DEFAULT 0, + matches_won INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_bots_owner ON bots(owner_id); +CREATE INDEX IF NOT EXISTS idx_bots_rating ON bots(rating DESC); + +-- Matches table: stores match metadata +CREATE TABLE IF NOT EXISTS matches ( + id TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'pending', + winner_id TEXT, + turns INTEGER, + end_reason TEXT, + map_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_matches_status ON matches(status); +CREATE INDEX IF NOT EXISTS idx_matches_created ON matches(created_at DESC); + +-- Match participants: links bots to matches +CREATE TABLE IF NOT EXISTS match_participants ( + id TEXT PRIMARY KEY, + match_id TEXT NOT NULL, + bot_id TEXT NOT NULL, + player_index INTEGER NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + rating_before REAL NOT NULL, + rating_after REAL, + rating_deviation_before REAL NOT NULL, + rating_deviation_after REAL, + FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE, + FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE, + UNIQUE(match_id, bot_id), + UNIQUE(match_id, player_index) +); + +CREATE INDEX IF NOT EXISTS idx_match_participants_match ON match_participants(match_id); +CREATE INDEX IF NOT EXISTS idx_match_participants_bot ON match_participants(bot_id); + +-- Jobs table: match execution jobs for workers +CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + match_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + worker_id TEXT, + claimed_at TEXT, + heartbeat_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT, + error_message TEXT, + FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); +CREATE INDEX IF NOT EXISTS idx_jobs_worker ON jobs(worker_id); +CREATE INDEX IF NOT EXISTS idx_jobs_heartbeat ON jobs(heartbeat_at); + +-- Rating history: tracks rating changes over time +CREATE TABLE IF NOT EXISTS rating_history ( + id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL, + match_id TEXT NOT NULL, + rating_before REAL NOT NULL, + rating_after REAL NOT NULL, + rating_deviation REAL NOT NULL, + recorded_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE, + FOREIGN KEY (match_id) REFERENCES matches(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_rating_history_bot ON rating_history(bot_id); +CREATE INDEX IF NOT EXISTS idx_rating_history_time ON rating_history(recorded_at DESC); + +-- Maps table: stores generated maps +CREATE TABLE IF NOT EXISTS maps ( + id TEXT PRIMARY KEY, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + walls TEXT NOT NULL, + spawns TEXT NOT NULL, + cores TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Bot secrets: stores API keys for bots (separate for security) +CREATE TABLE IF NOT EXISTS bot_secrets ( + bot_id TEXT PRIMARY KEY, + api_key_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (bot_id) REFERENCES bots(id) ON DELETE CASCADE +); diff --git a/worker-api/src/bots.ts b/worker-api/src/bots.ts new file mode 100644 index 0000000..a08858d --- /dev/null +++ b/worker-api/src/bots.ts @@ -0,0 +1,240 @@ +// Bot Management Endpoints + +import type { Env, Bot, CreateBotRequest, ApiResponse } from './types'; + +/** + * Generate a random API key (256-bit, hex-encoded) + */ +function generateApiKey(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** + * Hash an API key for storage + */ +async function hashApiKey(key: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(key); + const hash = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +/** + * POST /api/register - Register a new bot + */ +export async function registerBot( + env: Env, + request: CreateBotRequest +): Promise> { + // Validate request + if (!request.name || !request.owner_id || !request.endpoint_url) { + return { success: false, error: 'Missing required fields' }; + } + + // Validate endpoint URL + try { + new URL(request.endpoint_url); + } catch { + return { success: false, error: 'Invalid endpoint URL' }; + } + + const botId = crypto.randomUUID(); + const apiKey = generateApiKey(); + const apiKeyHash = await hashApiKey(apiKey); + const now = new Date().toISOString(); + + // Check if owner already has a bot with this name + const existing = await env.DB.prepare( + 'SELECT id FROM bots WHERE owner_id = ? AND name = ?' + ) + .bind(request.owner_id, request.name) + .first(); + + if (existing) { + return { success: false, error: 'Bot with this name already exists for this owner' }; + } + + // Create bot + await env.DB.prepare( + `INSERT INTO bots (id, name, owner_id, endpoint_url, api_key_hash, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + botId, + request.name, + request.owner_id, + request.endpoint_url, + apiKeyHash, + now, + now + ) + .run(); + + // Store API key hash separately + await env.DB.prepare( + `INSERT INTO bot_secrets (bot_id, api_key_hash, created_at) + VALUES (?, ?, ?)` + ) + .bind(botId, apiKeyHash, now) + .run(); + + return { + success: true, + data: { + id: botId, + api_key: apiKey, // Return the plain key only on creation + }, + }; +} + +/** + * GET /api/bots - List all bots + */ +export async function listBots(env: Env): Promise> { + const result = await env.DB.prepare( + `SELECT + id, name, owner_id, endpoint_url, rating, rating_deviation, rating_volatility, + created_at, updated_at, last_health_check, health_status, matches_played, matches_won + FROM bots + ORDER BY rating DESC` + ).all(); + + // Remove sensitive fields + const bots = (result.results || []).map((bot) => ({ + ...bot, + api_key_hash: '', + })); + + return { success: true, data: bots }; +} + +/** + * GET /api/bots/:id - Get bot details + */ +export async function getBot(env: Env, botId: string): Promise> { + const bot = await env.DB.prepare( + `SELECT + id, name, owner_id, endpoint_url, rating, rating_deviation, rating_volatility, + created_at, updated_at, last_health_check, health_status, matches_played, matches_won + FROM bots + WHERE id = ?` + ) + .bind(botId) + .first(); + + if (!bot) { + return { success: false, error: 'Bot not found' }; + } + + return { success: true, data: { ...bot, api_key_hash: '' } }; +} + +/** + * PUT /api/bots/:id - Update bot details + */ +export async function updateBot( + env: Env, + botId: string, + updates: { name?: string; endpoint_url?: string } +): Promise> { + const now = new Date().toISOString(); + + const setClauses: string[] = []; + const values: unknown[] = []; + + if (updates.name) { + setClauses.push('name = ?'); + values.push(updates.name); + } + + if (updates.endpoint_url) { + try { + new URL(updates.endpoint_url); + setClauses.push('endpoint_url = ?'); + values.push(updates.endpoint_url); + } catch { + return { success: false, error: 'Invalid endpoint URL' }; + } + } + + if (setClauses.length === 0) { + return { success: false, error: 'No valid updates provided' }; + } + + setClauses.push('updated_at = ?'); + values.push(now); + values.push(botId); + + const result = await env.DB.prepare( + `UPDATE bots SET ${setClauses.join(', ')} WHERE id = ?` + ) + .bind(...values) + .run(); + + if (result.meta.changes === 0) { + return { success: false, error: 'Bot not found' }; + } + + return { success: true }; +} + +/** + * POST /api/rotate-key - Rotate bot API key + */ +export async function rotateApiKey( + env: Env, + botId: string, + ownerId: string +): Promise> { + // Verify ownership + const bot = await env.DB.prepare('SELECT owner_id FROM bots WHERE id = ?') + .bind(botId) + .first<{ owner_id: string }>(); + + if (!bot) { + return { success: false, error: 'Bot not found' }; + } + + if (bot.owner_id !== ownerId) { + return { success: false, error: 'Not authorized' }; + } + + const newApiKey = generateApiKey(); + const apiKeyHash = await hashApiKey(newApiKey); + const now = new Date().toISOString(); + + // Update bot + await env.DB.prepare('UPDATE bots SET api_key_hash = ?, updated_at = ? WHERE id = ?') + .bind(apiKeyHash, now, botId) + .run(); + + // Update secret + await env.DB.prepare('UPDATE bot_secrets SET api_key_hash = ? WHERE bot_id = ?') + .bind(apiKeyHash, botId) + .run(); + + return { success: true, data: { api_key: newApiKey } }; +} + +/** + * GET /api/leaderboard - Get current leaderboard + */ +export async function getLeaderboard(env: Env): Promise> { + const result = await env.DB.prepare( + `SELECT + id, name, owner_id, rating, rating_deviation, matches_played, matches_won, + created_at, updated_at, health_status + FROM bots + WHERE matches_played > 0 + ORDER BY rating DESC + LIMIT 100` + ).all(); + + return { success: true, data: result.results || [] }; +} diff --git a/worker-api/src/cron.ts b/worker-api/src/cron.ts new file mode 100644 index 0000000..ea88fd9 --- /dev/null +++ b/worker-api/src/cron.ts @@ -0,0 +1,228 @@ +// Cron Job Handlers + +import type { Env, Bot } from './types'; + +/** + * Matchmaker cron: Create match jobs for bots that need games + * Runs every minute + */ +export async function runMatchmaker(env: Env): Promise<{ created: number }> { + const now = new Date().toISOString(); + + // Get bots that are healthy and have played fewer than 10 matches today + // For simplicity, we'll just pair bots randomly for now + // A more sophisticated system would consider rating proximity + + // Get active bots (healthy, played at least one match or registered recently) + const bots = await env.DB.prepare( + `SELECT id, rating, matches_played FROM bots + WHERE health_status = 'healthy' + ORDER BY RANDOM() + LIMIT 10` + ).all(); + + if (!bots.results || bots.results.length < 2) { + return { created: 0 }; + } + + // Get a random map + const map = await env.DB.prepare( + 'SELECT id FROM maps ORDER BY RANDOM() LIMIT 1' + ).first<{ id: string }>(); + + if (!map) { + return { created: 0 }; + } + + let created = 0; + + // Create matches in pairs + for (let i = 0; i < bots.results.length - 1; i += 2) { + const bot1 = bots.results[i]; + const bot2 = bots.results[i + 1]; + + // Check if these bots already have a pending match together + const existingMatch = await env.DB.prepare( + `SELECT m.id FROM matches m + JOIN match_participants mp1 ON m.id = mp1.match_id + JOIN match_participants mp2 ON m.id = mp2.match_id + WHERE m.status = 'pending' + AND mp1.bot_id = ? AND mp2.bot_id = ?` + ) + .bind(bot1.id, bot2.id) + .first(); + + if (existingMatch) { + continue; // Skip this pair + } + + // Create match + const matchId = crypto.randomUUID(); + await env.DB.prepare( + `INSERT INTO matches (id, status, map_id, created_at) + VALUES (?, 'pending', ?, ?)` + ) + .bind(matchId, map.id, now) + .run(); + + // Get bot ratings for participants + const bot1Data = await env.DB.prepare( + 'SELECT rating, rating_deviation FROM bots WHERE id = ?' + ) + .bind(bot1.id) + .first<{ rating: number; rating_deviation: number }>(); + + const bot2Data = await env.DB.prepare( + 'SELECT rating, rating_deviation FROM bots WHERE id = ?' + ) + .bind(bot2.id) + .first<{ rating: number; rating_deviation: number }>(); + + if (!bot1Data || !bot2Data) continue; + + // Create participants (player_index 0 and 1) + await env.DB.prepare( + `INSERT INTO match_participants (id, match_id, bot_id, player_index, score, rating_before, rating_deviation_before) + VALUES (?, ?, ?, 0, 0, ?, ?)` + ) + .bind(crypto.randomUUID(), matchId, bot1.id, bot1Data.rating, bot1Data.rating_deviation) + .run(); + + await env.DB.prepare( + `INSERT INTO match_participants (id, match_id, bot_id, player_index, score, rating_before, rating_deviation_before) + VALUES (?, ?, ?, 1, 0, ?, ?)` + ) + .bind(crypto.randomUUID(), matchId, bot2.id, bot2Data.rating, bot2Data.rating_deviation) + .run(); + + // Create job + await env.DB.prepare( + `INSERT INTO jobs (id, match_id, status, created_at) + VALUES (?, ?, 'pending', ?)` + ) + .bind(crypto.randomUUID(), matchId, now) + .run(); + + created++; + } + + return { created }; +} + +/** + * Health checker cron: Ping bot endpoints to check health + * Runs every 15 minutes + */ +export async function runHealthChecker(env: Env): Promise<{ checked: number }> { + const bots = await env.DB.prepare( + `SELECT id, endpoint_url FROM bots WHERE health_status != 'unhealthy' OR last_health_check IS NULL` + ).all<{ id: string; endpoint_url: string }>(); + + let checked = 0; + const now = new Date().toISOString(); + + for (const bot of bots.results || []) { + try { + // Simple health check - just try to connect + const response = await fetch(bot.endpoint_url, { + method: 'GET', + signal: AbortSignal.timeout(5000), // 5 second timeout + }); + + const status = response.ok ? 'healthy' : 'unhealthy'; + + await env.DB.prepare( + `UPDATE bots SET health_status = ?, last_health_check = ? WHERE id = ?` + ) + .bind(status, now, bot.id) + .run(); + + checked++; + } catch { + // Connection failed + await env.DB.prepare( + `UPDATE bots SET health_status = 'unhealthy', last_health_check = ? WHERE id = ?` + ) + .bind(now, bot.id) + .run(); + + checked++; + } + } + + return { checked }; +} + +/** + * Stale job reaper: Reclaim jobs that have timed out + * Runs every 5 minutes + */ +export async function runStaleJobReaper(env: Env): Promise<{ reclaimed: number }> { + const now = new Date(); + const staleThreshold = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago + const staleThresholdStr = staleThreshold.toISOString(); + + // Find jobs that have been claimed but haven't had a heartbeat in 5 minutes + const staleJobs = await env.DB.prepare( + `SELECT id, match_id FROM jobs + WHERE status = 'claimed' + AND heartbeat_at < ?` + ) + .bind(staleThresholdStr) + .all<{ id: string; match_id: string }>(); + + let reclaimed = 0; + + for (const job of staleJobs.results || []) { + // Reset the job to pending so another worker can claim it + await env.DB.prepare( + `UPDATE jobs SET + status = 'pending', + worker_id = NULL, + claimed_at = NULL, + heartbeat_at = NULL + WHERE id = ?` + ) + .bind(job.id) + .run(); + + // Reset match status to pending + await env.DB.prepare( + `UPDATE matches SET status = 'pending', started_at = NULL WHERE id = ?` + ) + .bind(job.match_id) + .run(); + + reclaimed++; + } + + return { reclaimed }; +} + +/** + * Dispatch cron handler based on event type + */ +export async function handleCron( + env: Env, + cron: string +): Promise<{ success: boolean; result: unknown }> { + // Parse cron expression to determine which handler to run + // */1 * * * * = matchmaker (every minute) + // */5 * * * * = stale job reaper (every 5 minutes) + // */15 * * * * = health checker (every 15 minutes) + + // The cron expression is passed, but we need to determine the type + // For simplicity, we'll check the pattern + if (cron === '*/1 * * * *' || cron.includes('*/1')) { + const result = await runMatchmaker(env); + return { success: true, result }; + } else if (cron === '*/5 * * * *' || cron.includes('*/5')) { + const result = await runStaleJobReaper(env); + return { success: true, result }; + } else if (cron === '*/15 * * * *' || cron.includes('*/15')) { + const result = await runHealthChecker(env); + return { success: true, result }; + } + + return { success: false, result: 'Unknown cron pattern' }; +} diff --git a/worker-api/src/glicko2.test.ts b/worker-api/src/glicko2.test.ts new file mode 100644 index 0000000..e3adbe8 --- /dev/null +++ b/worker-api/src/glicko2.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect } from 'vitest'; +import { + toGlicko2, + fromGlicko2, + updateRating, + g, + E, +} from './glicko2'; + +describe('Glicko-2 Rating System', () => { + describe('Scale Conversion', () => { + it('converts rating to Glicko-2 scale correctly', () => { + // Default rating 1500 should map to mu=0 + const result = toGlicko2(1500, 350); + expect(result.mu).toBe(0); + expect(result.phi).toBeCloseTo(350 / 173.7178, 10); + }); + + it('converts rating above default correctly', () => { + const result = toGlicko2(1900, 100); + expect(result.mu).toBeCloseTo(400 / 173.7178, 10); + expect(result.phi).toBeCloseTo(100 / 173.7178, 10); + }); + + it('converts rating below default correctly', () => { + const result = toGlicko2(1300, 200); + expect(result.mu).toBeCloseTo(-200 / 173.7178, 10); + expect(result.phi).toBeCloseTo(200 / 173.7178, 10); + }); + + it('round-trips correctly', () => { + const originalRating = 1650; + const originalRd = 150; + + const g2 = toGlicko2(originalRating, originalRd); + const result = fromGlicko2(g2); + + expect(result.rating).toBeCloseTo(originalRating, 10); + expect(result.rd).toBeCloseTo(originalRd, 10); + }); + }); + + describe('g function', () => { + it('returns 1 when phi is 0', () => { + expect(g(0)).toBe(1); + }); + + it('decreases as phi increases', () => { + const g1 = g(0.1); + const g2 = g(0.5); + const g3 = g(1.0); + + expect(g1).toBeGreaterThan(g2); + expect(g2).toBeGreaterThan(g3); + }); + + it('returns correct values for known inputs', () => { + // g(0.2) ≈ 0.9955 (from paper example) + expect(g(0.2)).toBeCloseTo(0.9955, 4); + }); + }); + + describe('E function', () => { + it('returns 0.5 when ratings are equal', () => { + const e = E(0, 0, 0.2); + expect(e).toBeCloseTo(0.5, 10); + }); + + it('returns > 0.5 when player rating is higher', () => { + const e = E(0.5, 0, 0.2); // Player rated higher + expect(e).toBeGreaterThan(0.5); + }); + + it('returns < 0.5 when opponent rating is higher', () => { + const e = E(0, 0.5, 0.2); // Opponent rated higher + expect(e).toBeLessThan(0.5); + }); + }); + + describe('Rating Updates', () => { + it('increases rating after win against equal opponent', () => { + const bot = { + id: 'test', + name: 'Test', + owner_id: 'owner', + endpoint_url: 'http://example.com', + api_key_hash: 'hash', + rating: 1500, + rating_deviation: 200, + rating_volatility: 0.06, + created_at: '2024-01-01', + updated_at: '2024-01-01', + last_health_check: null, + health_status: 'healthy' as const, + matches_played: 0, + matches_won: 0, + }; + + const opponents = [ + { rating: 1500, rd: 200, score: 1 }, // Win + ]; + + const result = updateRating(bot, opponents); + + // Rating should increase after winning + expect(result.rating).toBeGreaterThan(1500); + // RD should decrease after playing + expect(result.rd).toBeLessThan(200); + }); + + it('decreases rating after loss against equal opponent', () => { + const bot = { + id: 'test', + name: 'Test', + owner_id: 'owner', + endpoint_url: 'http://example.com', + api_key_hash: 'hash', + rating: 1500, + rating_deviation: 200, + rating_volatility: 0.06, + created_at: '2024-01-01', + updated_at: '2024-01-01', + last_health_check: null, + health_status: 'healthy' as const, + matches_played: 0, + matches_won: 0, + }; + + const opponents = [ + { rating: 1500, rd: 200, score: 0 }, // Loss + ]; + + const result = updateRating(bot, opponents); + + // Rating should decrease after losing + expect(result.rating).toBeLessThan(1500); + // RD should decrease after playing + expect(result.rd).toBeLessThan(200); + }); + + it('handles draw correctly', () => { + const bot = { + id: 'test', + name: 'Test', + owner_id: 'owner', + endpoint_url: 'http://example.com', + api_key_hash: 'hash', + rating: 1500, + rating_deviation: 200, + rating_volatility: 0.06, + created_at: '2024-01-01', + updated_at: '2024-01-01', + last_health_check: null, + health_status: 'healthy' as const, + matches_played: 0, + matches_won: 0, + }; + + const opponents = [ + { rating: 1500, rd: 200, score: 0.5 }, // Draw + ]; + + const result = updateRating(bot, opponents); + + // Rating should stay roughly the same against equal opponent + expect(result.rating).toBeCloseTo(1500, 1); + // RD should decrease after playing + expect(result.rd).toBeLessThan(200); + }); + + it('handles multiple opponents', () => { + const bot = { + id: 'test', + name: 'Test', + owner_id: 'owner', + endpoint_url: 'http://example.com', + api_key_hash: 'hash', + rating: 1500, + rating_deviation: 200, + rating_volatility: 0.06, + created_at: '2024-01-01', + updated_at: '2024-01-01', + last_health_check: null, + health_status: 'healthy' as const, + matches_played: 0, + matches_won: 0, + }; + + const opponents = [ + { rating: 1600, rd: 150, score: 1 }, // Win vs higher rated + { rating: 1400, rd: 150, score: 0 }, // Loss vs lower rated + ]; + + const result = updateRating(bot, opponents); + + // Both rating and RD should be updated + expect(result.rating).toBeGreaterThan(0); + expect(result.rd).toBeLessThan(200); + }); + + it('increases RD when no games played (rating decay)', () => { + const bot = { + id: 'test', + name: 'Test', + owner_id: 'owner', + endpoint_url: 'http://example.com', + api_key_hash: 'hash', + rating: 1500, + rating_deviation: 100, + rating_volatility: 0.06, + created_at: '2024-01-01', + updated_at: '2024-01-01', + last_health_check: null, + health_status: 'healthy' as const, + matches_played: 0, + matches_won: 0, + }; + + const result = updateRating(bot, []); + + // Rating should stay the same + expect(result.rating).toBe(1500); + // RD should increase (rating decay) + expect(result.rd).toBeGreaterThan(100); + }); + + it('constrains RD to maximum', () => { + const bot = { + id: 'test', + name: 'Test', + owner_id: 'owner', + endpoint_url: 'http://example.com', + api_key_hash: 'hash', + rating: 1500, + rating_deviation: 340, + rating_volatility: 0.5, // High volatility + created_at: '2024-01-01', + updated_at: '2024-01-01', + last_health_check: null, + health_status: 'healthy' as const, + matches_played: 0, + matches_won: 0, + }; + + const result = updateRating(bot, []); + + // RD should not exceed 350 + expect(result.rd).toBeLessThanOrEqual(350); + }); + }); + + describe('Real-world scenarios', () => { + it('matches expected rating change from Glicko-2 paper example', () => { + // This is a simplified test based on the Glicko-2 paper + // Player with rating 1500, RD 200 playing against: + // - Opponent 1: 1400, 30, win (score=1) + // - Opponent 2: 1550, 100, loss (score=0) + // - Opponent 3: 1700, 300, loss (score=0) + + const bot = { + id: 'test', + name: 'Test', + owner_id: 'owner', + endpoint_url: 'http://example.com', + api_key_hash: 'hash', + rating: 1500, + rating_deviation: 200, + rating_volatility: 0.06, + created_at: '2024-01-01', + updated_at: '2024-01-01', + last_health_check: null, + health_status: 'healthy' as const, + matches_played: 0, + matches_won: 0, + }; + + const opponents = [ + { rating: 1400, rd: 30, score: 1 }, + { rating: 1550, rd: 100, score: 0 }, + { rating: 1700, rd: 300, score: 0 }, + ]; + + const result = updateRating(bot, opponents); + + // The new rating should be in a reasonable range + // Based on the paper, expected new rating is approximately 1464 + expect(result.rating).toBeGreaterThan(1400); + expect(result.rating).toBeLessThan(1550); + expect(result.rd).toBeLessThan(200); + }); + }); +}); diff --git a/worker-api/src/glicko2.ts b/worker-api/src/glicko2.ts new file mode 100644 index 0000000..47c5e6b --- /dev/null +++ b/worker-api/src/glicko2.ts @@ -0,0 +1,309 @@ +// Glicko-2 Rating System Implementation +// Based on: http://www.glicko.net/glicko/glicko2.pdf + +import type { Env, Bot, MatchParticipant } from './types'; + +// Glicko-2 constants +const SCALE = 173.7178; // Rating scale conversion factor +const TAU = 0.5; // System constant (constrains volatility change) +const DEFAULT_RATING = 1500; +const DEFAULT_RD = 350; +const DEFAULT_VOLATILITY = 0.06; + +export interface Glicko2Rating { + mu: number; // Mean rating (Glicko-2 scale) + phi: number; // Rating deviation (Glicko-2 scale) + sigma: number; // Volatility +} + +/** + * Convert rating to Glicko-2 scale + */ +export function toGlicko2(rating: number, rd: number): Glicko2Rating { + return { + mu: (rating - DEFAULT_RATING) / SCALE, + phi: rd / SCALE, + sigma: DEFAULT_VOLATILITY, + }; +} + +/** + * Convert from Glicko-2 scale to original scale + */ +export function fromGlicko2(g2: Glicko2Rating): { rating: number; rd: number } { + return { + rating: g2.mu * SCALE + DEFAULT_RATING, + rd: g2.phi * SCALE, + }; +} + +/** + * Compute g(phi) function + */ +function g(phi: number): number { + return 1 / Math.sqrt(1 + (3 * phi * phi) / (Math.PI * Math.PI)); +} + +/** + * Compute E(mu, mu_j, phi_j) function + */ +function E(mu: number, mu_j: number, phi_j: number): number { + return 1 / (1 + Math.exp(-g(phi_j) * (mu - mu_j))); +} + +/** + * Compute new rating deviation (Step 5/6) + */ +function computeNewPhi(phi: number, v: number): number { + const phiSquared = phi * phi; + const vInverse = 1 / v; + return 1 / Math.sqrt(1 / phiSquared + vInverse); +} + +/** + * Iterative algorithm to compute new volatility (Step 5.4) + */ +function computeNewVolatility( + sigma: number, + phi: number, + v: number, + delta: number, + tau: number = TAU +): number { + let a = Math.log(sigma * sigma); + const epsilon = 0.000001; + + const f = (x: number): number => { + const expX = Math.exp(x); + const tmp = phi * phi + v + expX; + return ( + (expX * (delta * delta - phi * phi - v - expX)) / (2 * tmp * tmp) - + (x - a) / (tau * tau) + ); + }; + + // Set initial bounds + let A = a; + let B: number; + if (delta * delta > phi * phi + v) { + B = Math.log(delta * delta - phi * phi - v); + } else { + let k = 1; + while (f(a - k * tau) < 0) { + k++; + } + B = a - k * tau; + } + + // Illinois algorithm + let fA = f(A); + let fB = f(B); + + while (Math.abs(B - A) > epsilon) { + const C = A + ((A - B) * fA) / (fB - fA); + const fC = f(C); + + if (fC * fB <= 0) { + A = B; + fA = fB; + } else { + fA = fA / 2; + } + + B = C; + fB = fC; + } + + return Math.exp(A / 2); +} + +/** + * Calculate rating updates for a bot after a match + * @param bot The bot whose rating to update + * @param opponents Array of opponent ratings and game outcomes (1=win, 0.5=draw, 0=loss) + * @returns New rating values + */ +export function updateRating( + bot: Bot, + opponents: Array<{ + rating: number; + rd: number; + score: number; + }> +): { rating: number; rd: number; volatility: number } { + if (opponents.length === 0) { + // No games played - increase RD over time (rating decay) + const phi = bot.rating_deviation / SCALE; + const newPhi = Math.min(Math.sqrt(phi * phi + bot.rating_volatility * bot.rating_volatility), 350 / SCALE); + return { + rating: bot.rating, + rd: newPhi * SCALE, + volatility: bot.rating_volatility, + }; + } + + // Convert to Glicko-2 scale + const g2 = toGlicko2(bot.rating, bot.rating_deviation); + g2.sigma = bot.rating_volatility; + + // Step 3: Compute v (variance of game outcomes) + let vInverse = 0; + for (const opp of opponents) { + const oppG2 = toGlicko2(opp.rating, opp.rd); + const gPhi = g(oppG2.phi); + const eValue = E(g2.mu, oppG2.mu, oppG2.phi); + vInverse += gPhi * gPhi * eValue * (1 - eValue); + } + const v = 1 / vInverse; + + // Step 4: Compute delta (rating improvement) + let deltaSum = 0; + for (const opp of opponents) { + const oppG2 = toGlicko2(opp.rating, opp.rd); + const gPhi = g(oppG2.phi); + const eValue = E(g2.mu, oppG2.mu, oppG2.phi); + deltaSum += gPhi * (opp.score - eValue); + } + const delta = v * deltaSum; + + // Step 5: Compute new volatility + const newSigma = computeNewVolatility(g2.sigma, g2.phi, v, delta); + + // Step 6: Update phi + const phiStar = Math.sqrt(g2.phi * g2.phi + newSigma * newSigma); + + // Step 7: Update phi and mu + const newPhi = 1 / Math.sqrt(1 / (phiStar * phiStar) + 1 / v); + const newMu = g2.mu + newPhi * newPhi * deltaSum; + + // Convert back + const result = fromGlicko2({ mu: newMu, phi: newPhi, sigma: newSigma }); + + return { + rating: result.rating, + rd: result.rd, + volatility: newSigma, + }; +} + +/** + * Update ratings for all participants in a completed match + */ +export async function updateMatchRatings( + env: Env, + matchId: string, + participants: MatchParticipant[], + winnerId: string | null +): Promise { + // Get all bots involved + const botIds = participants.map((p) => p.bot_id); + const placeholders = botIds.map(() => '?').join(','); + + const bots = await env.DB.prepare( + `SELECT * FROM bots WHERE id IN (${placeholders})` + ) + .bind(...botIds) + .all(); + + if (!bots.results || bots.results.length !== participants.length) { + throw new Error('Could not find all participant bots'); + } + + const botMap = new Map(bots.results.map((b) => [b.id, b])); + + // Calculate new ratings for each participant + const updates: Array<{ + botId: string; + rating: number; + rd: number; + volatility: number; + won: boolean; + }> = []; + + for (const participant of participants) { + const bot = botMap.get(participant.bot_id); + if (!bot) continue; + + // Build opponent list + const opponents = participants + .filter((p) => p.bot_id !== participant.bot_id) + .map((opp) => { + const oppBot = botMap.get(opp.bot_id)!; + // Score: 1 for win, 0.5 for draw (if no winner), 0 for loss + let score = 0.5; + if (winnerId === participant.bot_id) { + score = 1; + } else if (winnerId === opp.bot_id) { + score = 0; + } + return { + rating: oppBot.rating, + rd: oppBot.rating_deviation, + score, + }; + }); + + const newRating = updateRating(bot, opponents); + const won = winnerId === participant.bot_id; + + updates.push({ + botId: participant.bot_id, + rating: newRating.rating, + rd: newRating.rd, + volatility: newRating.volatility, + won, + }); + } + + // Apply updates in a batch + const now = new Date().toISOString(); + + for (const update of updates) { + // Update bot rating + await env.DB.prepare( + `UPDATE bots SET + rating = ?, + rating_deviation = ?, + rating_volatility = ?, + matches_played = matches_played + 1, + matches_won = matches_won + ?, + updated_at = ? + WHERE id = ?` + ) + .bind( + update.rating, + update.rd, + update.volatility, + update.won ? 1 : 0, + now, + update.botId + ) + .run(); + + // Update participant with rating change + await env.DB.prepare( + `UPDATE match_participants SET + rating_after = ?, + rating_deviation_after = ? + WHERE match_id = ? AND bot_id = ?` + ) + .bind(update.rating, update.rd, matchId, update.botId) + .run(); + + // Record rating history + await env.DB.prepare( + `INSERT INTO rating_history (id, bot_id, match_id, rating_before, rating_after, rating_deviation, recorded_at) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + crypto.randomUUID(), + update.botId, + matchId, + botMap.get(update.botId)!.rating, + update.rating, + update.rd, + now + ) + .run(); + } +} diff --git a/worker-api/src/index.ts b/worker-api/src/index.ts new file mode 100644 index 0000000..34df940 --- /dev/null +++ b/worker-api/src/index.ts @@ -0,0 +1,203 @@ +// AI Code Battle Worker API +// Phase 4: Match Orchestration + +import type { Env, ApiResponse, ClaimJobRequest, SubmitResultRequest, CreateBotRequest } from './types'; +import { handleCron } from './cron'; +import { + getNextJob, + claimJob, + heartbeatJob, + submitResult, + failJob, +} from './jobs'; +import { + registerBot, + listBots, + getBot, + updateBot, + rotateApiKey, + getLeaderboard, +} from './bots'; + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const url = new URL(request.url); + const path = url.pathname; + const method = request.method; + + // CORS headers + const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key', + }; + + // Handle preflight + if (method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + // Helper for JSON responses + const json = (data: ApiResponse, status = 200): Response => { + return new Response(JSON.stringify(data), { + status, + headers: { + 'Content-Type': 'application/json', + ...corsHeaders, + }, + }); + }; + + // Helper to verify API key + const verifyApiKey = async (): Promise => { + const apiKey = request.headers.get('X-API-Key'); + if (!apiKey) return false; + return apiKey === env.API_KEY; + }; + + // Helper to parse JSON body + const parseBody = async (): Promise => { + try { + return await request.json(); + } catch { + return null; + } + }; + + try { + // Health check + if (path === '/health' || path === '/api/health') { + return json({ success: true, data: { status: 'healthy' } }); + } + + // ============ Job Endpoints (require API key) ============ + + if (path === '/api/jobs/next' && method === 'GET') { + if (!(await verifyApiKey())) { + return json({ success: false, error: 'Unauthorized' }, 401); + } + const result = await getNextJob(env); + return json(result); + } + + if (path.match(/^\/api\/jobs\/[^/]+\/claim$/) && method === 'POST') { + if (!(await verifyApiKey())) { + return json({ success: false, error: 'Unauthorized' }, 401); + } + const jobId = path.split('/')[3]; + const body = await parseBody(); + if (!body?.worker_id) { + return json({ success: false, error: 'Missing worker_id' }, 400); + } + const result = await claimJob(env, jobId, body.worker_id); + return json(result, result.success ? 200 : 400); + } + + if (path.match(/^\/api\/jobs\/[^/]+\/heartbeat$/) && method === 'POST') { + if (!(await verifyApiKey())) { + return json({ success: false, error: 'Unauthorized' }, 401); + } + const jobId = path.split('/')[3]; + const body = await parseBody<{ worker_id: string }>(); + if (!body?.worker_id) { + return json({ success: false, error: 'Missing worker_id' }, 400); + } + const result = await heartbeatJob(env, jobId, body.worker_id); + return json(result, result.success ? 200 : 400); + } + + if (path.match(/^\/api\/jobs\/[^/]+\/result$/) && method === 'POST') { + if (!(await verifyApiKey())) { + return json({ success: false, error: 'Unauthorized' }, 401); + } + const jobId = path.split('/')[3]; + const body = await parseBody(); + if (!body) { + return json({ success: false, error: 'Invalid request body' }, 400); + } + const result = await submitResult(env, jobId, body); + return json(result, result.success ? 200 : 400); + } + + if (path.match(/^\/api\/jobs\/[^/]+\/fail$/) && method === 'POST') { + if (!(await verifyApiKey())) { + return json({ success: false, error: 'Unauthorized' }, 401); + } + const jobId = path.split('/')[3]; + const body = await parseBody<{ worker_id: string; error_message: string }>(); + if (!body?.worker_id || !body?.error_message) { + return json({ success: false, error: 'Missing required fields' }, 400); + } + const result = await failJob(env, jobId, body.worker_id, body.error_message); + return json(result, result.success ? 200 : 400); + } + + // ============ Bot Endpoints (public or owner-verified) ============ + + if (path === '/api/register' && method === 'POST') { + const body = await parseBody(); + if (!body) { + return json({ success: false, error: 'Invalid request body' }, 400); + } + const result = await registerBot(env, body); + return json(result, result.success ? 201 : 400); + } + + if (path === '/api/bots' && method === 'GET') { + const result = await listBots(env); + return json(result); + } + + if (path.match(/^\/api\/bots\/[^/]+$/) && method === 'GET') { + const botId = path.split('/')[3]; + const result = await getBot(env, botId); + return json(result, result.success ? 200 : 404); + } + + if (path.match(/^\/api\/bots\/[^/]+$/) && method === 'PUT') { + const botId = path.split('/')[3]; + const body = await parseBody<{ name?: string; endpoint_url?: string }>(); + if (!body) { + return json({ success: false, error: 'Invalid request body' }, 400); + } + const result = await updateBot(env, botId, body); + return json(result, result.success ? 200 : 400); + } + + if (path === '/api/rotate-key' && method === 'POST') { + const body = await parseBody<{ bot_id: string; owner_id: string }>(); + if (!body?.bot_id || !body?.owner_id) { + return json({ success: false, error: 'Missing required fields' }, 400); + } + const result = await rotateApiKey(env, body.bot_id, body.owner_id); + return json(result, result.success ? 200 : 400); + } + + if (path === '/api/leaderboard' && method === 'GET') { + const result = await getLeaderboard(env); + return json(result); + } + + // 404 for unmatched routes + return json({ success: false, error: 'Not found' }, 404); + } catch (error) { + console.error('Worker error:', error); + return json( + { success: false, error: 'Internal server error' }, + 500 + ); + } + }, + + async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise { + const cron = event.cron; + console.log(`Running scheduled task: ${cron}`); + + try { + const result = await handleCron(env, cron); + console.log(`Cron result:`, result); + } catch (error) { + console.error(`Cron error:`, error); + } + }, +}; diff --git a/worker-api/src/jobs.ts b/worker-api/src/jobs.ts new file mode 100644 index 0000000..655703f --- /dev/null +++ b/worker-api/src/jobs.ts @@ -0,0 +1,244 @@ +// Job Coordination Endpoints + +import type { Env, Job, Match, MatchParticipant, JobClaimResponse, ApiResponse, SubmitResultRequest } from './types'; +import { updateMatchRatings } from './glicko2'; + +/** + * GET /api/jobs/next - Get next available job for worker + */ +export async function getNextJob(env: Env): Promise> { + // Find a pending job, ordered by creation time + const result = await env.DB.prepare( + `SELECT * FROM jobs + WHERE status = 'pending' + ORDER BY created_at ASC + LIMIT 1` + ).first(); + + return { success: true, data: result || null }; +} + +/** + * POST /api/jobs/:id/claim - Claim a job for execution + */ +export async function claimJob( + env: Env, + jobId: string, + workerId: string +): Promise> { + const now = new Date().toISOString(); + + // Try to claim the job atomically + const result = await env.DB.prepare( + `UPDATE jobs SET + status = 'claimed', + worker_id = ?, + claimed_at = ?, + heartbeat_at = ? + WHERE id = ? AND status = 'pending'` + ) + .bind(workerId, now, now, jobId) + .run(); + + if (result.meta.changes === 0) { + return { success: false, error: 'Job not found or already claimed' }; + } + + // Get the job details + const job = await env.DB.prepare('SELECT * FROM jobs WHERE id = ?') + .bind(jobId) + .first(); + + if (!job) { + return { success: false, error: 'Job not found' }; + } + + // Get match details + const match = await env.DB.prepare('SELECT * FROM matches WHERE id = ?') + .bind(job.match_id) + .first(); + + if (!match) { + return { success: false, error: 'Match not found' }; + } + + // Update match status to running + await env.DB.prepare( + `UPDATE matches SET status = 'running', started_at = ? WHERE id = ?` + ) + .bind(now, match.id) + .run(); + + // Get participants with their ratings + const participants = await env.DB.prepare( + `SELECT * FROM match_participants WHERE match_id = ?` + ) + .bind(match.id) + .all(); + + // Get bot details (endpoint URLs) + const botIds = participants.results.map((p) => p.bot_id); + const placeholders = botIds.map(() => '?').join(','); + const bots = await env.DB.prepare( + `SELECT id, endpoint_url FROM bots WHERE id IN (${placeholders})` + ) + .bind(...botIds) + .all<{ id: string; endpoint_url: string }>(); + + // Get bot secrets (API keys for HMAC auth) + const secrets = await env.DB.prepare( + `SELECT bot_id, api_key_hash as secret FROM bot_secrets WHERE bot_id IN (${placeholders})` + ) + .bind(...botIds) + .all<{ bot_id: string; secret: string }>(); + + // Get map details + const map = await env.DB.prepare('SELECT * FROM maps WHERE id = ?') + .bind(match.map_id) + .first<{ id: string; width: number; height: number; walls: string; spawns: string; cores: string }>(); + + if (!map) { + return { success: false, error: 'Map not found' }; + } + + return { + success: true, + data: { + job: job, + match: match, + participants: participants.results, + map: map, + bots: bots.results, + bot_secrets: secrets.results, + }, + }; +} + +/** + * POST /api/jobs/:id/heartbeat - Update job heartbeat + */ +export async function heartbeatJob( + env: Env, + jobId: string, + workerId: string +): Promise> { + const now = new Date().toISOString(); + + const result = await env.DB.prepare( + `UPDATE jobs SET heartbeat_at = ? WHERE id = ? AND worker_id = ?` + ) + .bind(now, jobId, workerId) + .run(); + + if (result.meta.changes === 0) { + return { success: false, error: 'Job not found or not owned by worker' }; + } + + return { success: true }; +} + +/** + * POST /api/jobs/:id/result - Submit job result + */ +export async function submitResult( + env: Env, + jobId: string, + result: SubmitResultRequest +): Promise> { + const now = new Date().toISOString(); + + // Get the job + const job = await env.DB.prepare('SELECT * FROM jobs WHERE id = ?') + .bind(jobId) + .first(); + + if (!job) { + return { success: false, error: 'Job not found' }; + } + + if (job.status !== 'claimed' && job.status !== 'running') { + return { success: false, error: 'Job not in a valid state for result submission' }; + } + + // Get participants + const participants = await env.DB.prepare( + 'SELECT * FROM match_participants WHERE match_id = ?' + ) + .bind(job.match_id) + .all(); + + // Update scores + for (const [botId, score] of Object.entries(result.scores)) { + await env.DB.prepare( + `UPDATE match_participants SET score = ? WHERE match_id = ? AND bot_id = ?` + ) + .bind(score, job.match_id, botId) + .run(); + } + + // Update ratings using Glicko-2 + await updateMatchRatings(env, job.match_id, participants.results, result.winner_id); + + // Update job status + await env.DB.prepare( + `UPDATE jobs SET status = 'completed', completed_at = ? WHERE id = ?` + ) + .bind(now, jobId) + .run(); + + // Update match status + await env.DB.prepare( + `UPDATE matches SET + status = 'completed', + winner_id = ?, + turns = ?, + end_reason = ?, + completed_at = ? + WHERE id = ?` + ) + .bind(result.winner_id, result.turns, result.end_reason, now, job.match_id) + .run(); + + return { success: true }; +} + +/** + * POST /api/jobs/:id/fail - Mark job as failed + */ +export async function failJob( + env: Env, + jobId: string, + workerId: string, + errorMessage: string +): Promise> { + const now = new Date().toISOString(); + + const result = await env.DB.prepare( + `UPDATE jobs SET + status = 'failed', + completed_at = ?, + error_message = ? + WHERE id = ? AND worker_id = ?` + ) + .bind(now, errorMessage, jobId, workerId) + .run(); + + if (result.meta.changes === 0) { + return { success: false, error: 'Job not found or not owned by worker' }; + } + + // Also update match status + const job = await env.DB.prepare('SELECT match_id FROM jobs WHERE id = ?') + .bind(jobId) + .first<{ match_id: string }>(); + + if (job) { + await env.DB.prepare( + `UPDATE matches SET status = 'failed', completed_at = ? WHERE id = ?` + ) + .bind(now, job.match_id) + .run(); + } + + return { success: true }; +} diff --git a/worker-api/src/types.ts b/worker-api/src/types.ts new file mode 100644 index 0000000..7560b67 --- /dev/null +++ b/worker-api/src/types.ts @@ -0,0 +1,122 @@ +// AI Code Battle Worker Types + +export interface Env { + DB: D1Database; + API_KEY: string; + ENVIRONMENT: string; +} + +// Bot types +export interface Bot { + id: string; + name: string; + owner_id: string; + endpoint_url: string; + api_key_hash: string; + rating: number; + rating_deviation: number; + rating_volatility: number; + created_at: string; + updated_at: string; + last_health_check: string | null; + health_status: 'healthy' | 'unhealthy' | 'unknown'; + matches_played: number; + matches_won: number; +} + +export interface CreateBotRequest { + name: string; + owner_id: string; + endpoint_url: string; +} + +// Match types +export type MatchStatus = 'pending' | 'running' | 'completed' | 'failed'; + +export interface Match { + id: string; + status: MatchStatus; + winner_id: string | null; + turns: number | null; + end_reason: string | null; + map_id: string; + created_at: string; + started_at: string | null; + completed_at: string | null; +} + +export interface MatchParticipant { + id: string; + match_id: string; + bot_id: string; + player_index: number; + score: number; + rating_before: number; + rating_after: number | null; + rating_deviation_before: number; + rating_deviation_after: number | null; +} + +// Job types +export type JobStatus = 'pending' | 'claimed' | 'running' | 'completed' | 'failed' | 'timeout'; + +export interface Job { + id: string; + match_id: string; + status: JobStatus; + worker_id: string | null; + claimed_at: string | null; + heartbeat_at: string | null; + created_at: string; + completed_at: string | null; + error_message: string | null; +} + +export interface ClaimJobRequest { + worker_id: string; +} + +export interface SubmitResultRequest { + winner_id: string; + turns: number; + end_reason: string; + replay_url: string; + scores: Record; +} + +// Rating types +export interface RatingChange { + bot_id: string; + rating_before: number; + rating_after: number; + rating_deviation: number; +} + +// API Response types +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; +} + +export interface JobClaimResponse { + job: Job; + match: Match; + participants: MatchParticipant[]; + map: { + id: string; + width: number; + height: number; + walls: string; + spawns: string; + cores: string; + }; + bots: Array<{ + id: string; + endpoint_url: string; + }>; + bot_secrets: Array<{ + bot_id: string; + secret: string; + }>; +} diff --git a/worker-api/tsconfig.json b/worker-api/tsconfig.json new file mode 100644 index 0000000..faa3811 --- /dev/null +++ b/worker-api/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/worker-api/vitest.config.ts b/worker-api/vitest.config.ts new file mode 100644 index 0000000..ba7eef0 --- /dev/null +++ b/worker-api/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + globals: true, + }, +}); diff --git a/worker-api/wrangler.toml b/worker-api/wrangler.toml new file mode 100644 index 0000000..840a0cb --- /dev/null +++ b/worker-api/wrangler.toml @@ -0,0 +1,23 @@ +name = "acb-api" +main = "src/index.ts" +compatibility_date = "2025-03-10" +compatibility_flags = ["nodejs_compat"] + +[[d1_databases]] +binding = "DB" +database_name = "acb-db" +database_id = "placeholder-will-be-set-on-deploy" + +[vars] +ENVIRONMENT = "development" + +[triggers] +crons = [ + "*/1 * * * *", # Matchmaker: every minute + "*/5 * * * *", # Stale job reaper: every 5 minutes + "*/15 * * * *" # Health checker: every 15 minutes +] + +# API key for worker authentication (set via wrangler secret put API_KEY) +# [secrets] +# API_KEY