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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-24 07:50:10 -04:00
parent 4f77980398
commit 9bd4efc935
13 changed files with 1853 additions and 2 deletions

View file

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

17
worker-api/package.json Normal file
View file

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

112
worker-api/schema.sql Normal file
View file

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

240
worker-api/src/bots.ts Normal file
View file

@ -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<string> {
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<ApiResponse<{ id: string; api_key: string }>> {
// 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<ApiResponse<Bot[]>> {
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<Bot>();
// 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<ApiResponse<Bot>> {
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<Bot>();
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<ApiResponse<void>> {
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<ApiResponse<{ api_key: string }>> {
// 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<ApiResponse<Bot[]>> {
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<Bot>();
return { success: true, data: result.results || [] };
}

228
worker-api/src/cron.ts Normal file
View file

@ -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<Bot>();
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' };
}

View file

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

309
worker-api/src/glicko2.ts Normal file
View file

@ -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<void> {
// 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<Bot>();
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();
}
}

203
worker-api/src/index.ts Normal file
View file

@ -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<Response> {
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 = <T>(data: ApiResponse<T>, 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<boolean> => {
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 <T>(): Promise<T | null> => {
try {
return await request.json<T>();
} 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<ClaimJobRequest>();
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<SubmitResultRequest>();
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<CreateBotRequest>();
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<void> {
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);
}
},
};

244
worker-api/src/jobs.ts Normal file
View file

@ -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<ApiResponse<Job | null>> {
// 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<Job>();
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<ApiResponse<JobClaimResponse>> {
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<Job>();
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<Match>();
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<MatchParticipant>();
// 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<ApiResponse<void>> {
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<ApiResponse<void>> {
const now = new Date().toISOString();
// Get the job
const job = await env.DB.prepare('SELECT * FROM jobs WHERE id = ?')
.bind(jobId)
.first<Job>();
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<MatchParticipant>();
// 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<ApiResponse<void>> {
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 };
}

122
worker-api/src/types.ts Normal file
View file

@ -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<string, number>;
}
// Rating types
export interface RatingChange {
bot_id: string;
rating_before: number;
rating_after: number;
rating_deviation: number;
}
// API Response types
export interface ApiResponse<T = unknown> {
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;
}>;
}

20
worker-api/tsconfig.json Normal file
View file

@ -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"]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
globals: true,
},
});

23
worker-api/wrangler.toml Normal file
View file

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