diff --git a/PROGRESS.md b/PROGRESS.md index ef9e5cc..cba8e47 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ ## Current Phase: Phase 4 - Match Orchestration -**Status: 🔄 In Progress** +**Status: ✅ COMPLETE** ### Phase 4 Progress @@ -27,6 +27,9 @@ - PUT /api/bots/:id - Update bot - POST /api/rotate-key - Rotate API key - GET /api/leaderboard - Get leaderboard +- [x] Data export endpoint (`worker-api/src/export.ts`) + - GET /api/data/export - Export all data for index building + - Returns bots, matches, rating history - [x] Cron handlers (`worker-api/src/cron.ts`) - Matchmaker (every minute) - Creates match jobs - Health checker (every 15 min) - Pings bot endpoints @@ -39,7 +42,15 @@ - Submits results back to Worker API - Retry logic with exponential backoff - API client tests (10 tests) -- [ ] Rackspace index builder +- [x] Index builder container (`cmd/acb-indexer/`) + - Fetches data from Worker API via `/api/data/export` + - Generates static JSON index files: + - `leaderboard.json` - Sorted bot rankings + - `bots/index.json` - Bot directory + - `bots/{bot_id}.json` - Per-bot profiles with rating history + - `matches/index.json` - Recent match list + - Optional deploy to Cloudflare Pages + - Unit tests (6 tests) ### Phase 3 Completed @@ -121,7 +132,19 @@ | Score overlay | ✅ Complete | | Loads replay JSON from file or URL | ✅ Complete | -## Next Phase: Phase 4 - Match Orchestration +### Phase 4 Exit Criteria + +| Criterion | Status | +|-----------|--------| +| Matchmaker cron creates jobs in D1 | ✅ Complete | +| Workers claim and execute matches | ✅ Complete | +| Replays land in R2 | ✅ Complete | +| Results flow into D1 | ✅ Complete | +| Ratings update via Glicko-2 | ✅ Complete | +| Leaderboard.json rebuilds automatically | ✅ Complete | +| Stale job reaper recovers from worker disappearance | ✅ Complete | + +## Next Phase: Phase 5 - Web Platform **Status: Ready to start** @@ -144,11 +167,20 @@ ai-code-battle/ ├── cmd/ │ ├── acb-local/ # CLI match runner │ ├── acb-mapgen/ # Map generator -│ └── acb-worker/ # Match execution worker -│ ├── main.go # Worker entry point -│ ├── api.go # Worker API client -│ ├── api_test.go # API client tests -│ └── r2.go # R2 upload client +│ ├── acb-worker/ # Match execution worker +│ │ ├── main.go # Worker entry point +│ │ ├── api.go # Worker API client +│ │ ├── api_test.go # API client tests +│ │ └── r2.go # R2 upload client +│ └── acb-indexer/ # Index builder +│ ├── package.json +│ ├── Dockerfile +│ └── src/ +│ ├── index.ts # Entry point +│ ├── api.ts # Worker API client +│ ├── generator.ts # Index file generator +│ ├── writer.ts # File system writer +│ └── generator.test.ts ├── worker-api/ │ ├── package.json # npm dependencies │ ├── wrangler.toml # Cloudflare Worker config @@ -159,6 +191,7 @@ ai-code-battle/ │ ├── glicko2.test.ts # Rating system tests │ ├── jobs.ts # Job coordination endpoints │ ├── bots.ts # Bot management endpoints +│ ├── export.ts # Data export endpoint │ └── cron.ts # Cron handlers ├── web/ │ ├── package.json # npm dependencies diff --git a/cmd/acb-indexer/.gitignore b/cmd/acb-indexer/.gitignore new file mode 100644 index 0000000..1d98640 --- /dev/null +++ b/cmd/acb-indexer/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +data/ +*.log diff --git a/cmd/acb-indexer/Dockerfile b/cmd/acb-indexer/Dockerfile new file mode 100644 index 0000000..b3444df --- /dev/null +++ b/cmd/acb-indexer/Dockerfile @@ -0,0 +1,33 @@ +# AI Code Battle Index Builder Container +# Generates static JSON index files and deploys to Cloudflare Pages + +FROM node:20-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Copy source code +COPY tsconfig.json ./ +COPY src/ ./src/ + +# Build TypeScript +RUN npm run build + +# Create output directory +RUN mkdir -p /app/data + +# Environment variables (set at runtime) +# API_URL - Worker API URL (e.g., https://api.aicodebattle.com) +# API_KEY - Worker API key +# OUTPUT_DIR - Output directory (default: /app/data) +# DEPLOY_COMMAND - Optional deploy command (e.g., wrangler pages deploy) + +ENV OUTPUT_DIR=/app/data + +# Run the index builder +CMD ["node", "dist/index.js"] diff --git a/cmd/acb-indexer/package.json b/cmd/acb-indexer/package.json new file mode 100644 index 0000000..d6aae1f --- /dev/null +++ b/cmd/acb-indexer/package.json @@ -0,0 +1,24 @@ +{ + "name": "acb-indexer", + "version": "1.0.0", + "description": "AI Code Battle Index Builder - Generates static JSON index files for Cloudflare Pages", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx src/index.ts", + "test": "vitest" + }, + "keywords": ["aicodebattle", "indexer", "cloudflare-pages"], + "license": "MIT", + "dependencies": { + "dotenv": "^16.4.5" + }, + "devDependencies": { + "@types/node": "^20.11.24", + "tsx": "^4.7.1", + "typescript": "^5.3.3", + "vitest": "^1.3.1" + } +} diff --git a/cmd/acb-indexer/src/api.ts b/cmd/acb-indexer/src/api.ts new file mode 100644 index 0000000..56c0905 --- /dev/null +++ b/cmd/acb-indexer/src/api.ts @@ -0,0 +1,42 @@ +// API Client for fetching data from Worker API + +import type { ApiClientConfig, ExportData } from './types.js'; + +export class ApiClient { + private apiUrl: string; + private apiKey: string; + + constructor(config: ApiClientConfig) { + this.apiUrl = config.apiUrl.replace(/\/$/, ''); + this.apiKey = config.apiKey; + } + + /** + * Fetch all data needed for index building + */ + async fetchExportData(): Promise { + const response = await fetch(`${this.apiUrl}/api/data/export`, { + headers: { + 'X-API-Key': this.apiKey, + 'Accept': 'application/json', + }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`API request failed: ${response.status} - ${text}`); + } + + const result = await response.json() as { success: boolean; data?: ExportData; error?: string }; + + if (!result.success) { + throw new Error(`API returned error: ${result.error}`); + } + + if (!result.data) { + throw new Error('API returned no data'); + } + + return result.data; + } +} diff --git a/cmd/acb-indexer/src/generator.test.ts b/cmd/acb-indexer/src/generator.test.ts new file mode 100644 index 0000000..4a40ded --- /dev/null +++ b/cmd/acb-indexer/src/generator.test.ts @@ -0,0 +1,160 @@ +// Index Generator Tests + +import { describe, it, expect } from 'vitest'; +import { IndexGenerator } from './generator.js'; +import type { ExportData, ExportBot, ExportMatch } from './types.js'; + +function createMockData(): ExportData { + const bots: ExportBot[] = [ + { + id: 'bot-1', + name: 'TestBot1', + owner_id: 'owner-1', + rating: 1500, + rating_deviation: 50, + rating_volatility: 0.06, + matches_played: 10, + matches_won: 7, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-03-01T00:00:00Z', + health_status: 'healthy', + }, + { + id: 'bot-2', + name: 'TestBot2', + owner_id: 'owner-2', + rating: 1450, + rating_deviation: 60, + rating_volatility: 0.07, + matches_played: 5, + matches_won: 2, + created_at: '2026-01-15T00:00:00Z', + updated_at: '2026-03-01T00:00:00Z', + health_status: 'healthy', + }, + { + id: 'bot-3', + name: 'UnrankedBot', + owner_id: 'owner-3', + rating: 1200, + rating_deviation: 350, + rating_volatility: 0.06, + matches_played: 0, + matches_won: 0, + created_at: '2026-02-01T00:00:00Z', + updated_at: '2026-02-01T00:00:00Z', + health_status: 'unknown', + }, + ]; + + const matches: ExportMatch[] = [ + { + id: 'match-1', + status: 'completed', + winner_id: 'bot-1', + turns: 50, + end_reason: 'domination', + map_id: 'map-1', + created_at: '2026-03-01T10:00:00Z', + completed_at: '2026-03-01T10:05:00Z', + participants: [ + { + bot_id: 'bot-1', + player_index: 0, + score: 100, + rating_before: 1480, + rating_after: 1500, + }, + { + bot_id: 'bot-2', + player_index: 1, + score: 50, + rating_before: 1470, + rating_after: 1450, + }, + ], + }, + ]; + + return { + bots, + matches, + rating_history: [ + { + bot_id: 'bot-1', + rating: 1480, + rating_deviation: 55, + recorded_at: '2026-02-15T00:00:00Z', + }, + { + bot_id: 'bot-1', + rating: 1500, + rating_deviation: 50, + recorded_at: '2026-03-01T00:00:00Z', + }, + ], + generated_at: '2026-03-24T08:00:00Z', + }; +} + +describe('IndexGenerator', () => { + it('generates leaderboard with correct rankings', () => { + const generator = new IndexGenerator(createMockData()); + const leaderboard = generator.generateLeaderboard(); + + expect(leaderboard.updated_at).toBe('2026-03-24T08:00:00Z'); + expect(leaderboard.entries).toHaveLength(2); // Only bots with matches + expect(leaderboard.entries[0].bot_id).toBe('bot-1'); + expect(leaderboard.entries[0].rank).toBe(1); + expect(leaderboard.entries[0].rating).toBe(1500); + expect(leaderboard.entries[0].win_rate).toBe(70); // 7/10 * 100 + }); + + it('generates bot directory', () => { + const generator = new IndexGenerator(createMockData()); + const directory = generator.generateBotDirectory(); + + expect(directory.bots).toHaveLength(3); + expect(directory.bots[0].id).toBe('bot-1'); + expect(directory.bots[0].name).toBe('TestBot1'); + }); + + it('generates bot profile with rating history', () => { + const generator = new IndexGenerator(createMockData()); + const profile = generator.generateBotProfile('bot-1'); + + expect(profile).not.toBeNull(); + expect(profile!.id).toBe('bot-1'); + expect(profile!.name).toBe('TestBot1'); + expect(profile!.rating_history).toHaveLength(2); + expect(profile!.recent_matches).toHaveLength(1); + expect(profile!.recent_matches[0].participants[0].won).toBe(true); + }); + + it('returns null for non-existent bot profile', () => { + const generator = new IndexGenerator(createMockData()); + const profile = generator.generateBotProfile('non-existent'); + + expect(profile).toBeNull(); + }); + + it('generates match index', () => { + const generator = new IndexGenerator(createMockData()); + const matchIndex = generator.generateMatchIndex(); + + expect(matchIndex.matches).toHaveLength(1); + expect(matchIndex.matches[0].id).toBe('match-1'); + expect(matchIndex.matches[0].winner_id).toBe('bot-1'); + expect(matchIndex.matches[0].participants).toHaveLength(2); + }); + + it('generates all indexes at once', () => { + const generator = new IndexGenerator(createMockData()); + const all = generator.generateAll(); + + expect(all.leaderboard.entries).toHaveLength(2); + expect(all.botDirectory.bots).toHaveLength(3); + expect(all.botProfiles.size).toBe(3); + expect(all.matchIndex.matches).toHaveLength(1); + }); +}); diff --git a/cmd/acb-indexer/src/generator.ts b/cmd/acb-indexer/src/generator.ts new file mode 100644 index 0000000..410be0b --- /dev/null +++ b/cmd/acb-indexer/src/generator.ts @@ -0,0 +1,166 @@ +// Index Generator - Creates static JSON index files + +import type { + ExportData, + ExportBot, + ExportMatch, + LeaderboardIndex, + LeaderboardEntry, + BotDirectory, + BotDirectoryEntry, + BotProfile, + MatchIndex, + MatchSummary, +} from './types.js'; + +export class IndexGenerator { + private data: ExportData; + private botNameMap: Map; + + constructor(data: ExportData) { + this.data = data; + this.botNameMap = new Map(data.bots.map(b => [b.id, b.name])); + } + + /** + * Generate leaderboard.json + */ + generateLeaderboard(): LeaderboardIndex { + const entries: LeaderboardEntry[] = this.data.bots + .filter(bot => bot.matches_played > 0) + .map((bot, index) => ({ + rank: index + 1, + bot_id: bot.id, + name: bot.name, + owner_id: bot.owner_id, + rating: Math.round(bot.rating), + rating_deviation: Math.round(bot.rating_deviation * 10) / 10, + matches_played: bot.matches_played, + matches_won: bot.matches_won, + win_rate: bot.matches_played > 0 + ? Math.round((bot.matches_won / bot.matches_played) * 1000) / 10 + : 0, + health_status: bot.health_status, + })); + + return { + updated_at: this.data.generated_at, + entries, + }; + } + + /** + * Generate bots/index.json - bot directory + */ + generateBotDirectory(): BotDirectory { + const bots: BotDirectoryEntry[] = this.data.bots.map(bot => ({ + id: bot.id, + name: bot.name, + rating: Math.round(bot.rating), + matches_played: bot.matches_played, + win_rate: bot.matches_played > 0 + ? Math.round((bot.matches_won / bot.matches_played) * 1000) / 10 + : 0, + })); + + return { + updated_at: this.data.generated_at, + bots, + }; + } + + /** + * Generate individual bot profile + */ + generateBotProfile(botId: string): BotProfile | null { + const bot = this.data.bots.find(b => b.id === botId); + if (!bot) return null; + + // Get rating history for this bot + const ratingHistory = this.data.rating_history + .filter(h => h.bot_id === botId) + .sort((a, b) => a.recorded_at.localeCompare(b.recorded_at)); + + // Get recent matches for this bot (last 20) + const recentMatches = this.data.matches + .filter(m => m.participants.some(p => p.bot_id === botId)) + .slice(0, 20) + .map(m => this.generateMatchSummary(m)); + + return { + id: bot.id, + name: bot.name, + owner_id: bot.owner_id, + rating: Math.round(bot.rating), + rating_deviation: Math.round(bot.rating_deviation * 10) / 10, + rating_volatility: Math.round(bot.rating_volatility * 10000) / 10000, + matches_played: bot.matches_played, + matches_won: bot.matches_won, + win_rate: bot.matches_played > 0 + ? Math.round((bot.matches_won / bot.matches_played) * 1000) / 10 + : 0, + health_status: bot.health_status, + created_at: bot.created_at, + updated_at: bot.updated_at, + rating_history: ratingHistory, + recent_matches: recentMatches, + }; + } + + /** + * Generate matches/index.json - recent match list + */ + generateMatchIndex(): MatchIndex { + const matches = this.data.matches.map(m => this.generateMatchSummary(m)); + + return { + updated_at: this.data.generated_at, + matches, + }; + } + + /** + * Generate match summary for a single match + */ + private generateMatchSummary(match: ExportMatch): MatchSummary { + return { + id: match.id, + completed_at: match.completed_at, + participants: match.participants.map(p => ({ + bot_id: p.bot_id, + name: this.botNameMap.get(p.bot_id) || 'Unknown', + score: p.score, + won: p.bot_id === match.winner_id, + })), + winner_id: match.winner_id, + turns: match.turns, + end_reason: match.end_reason, + }; + } + + /** + * Generate all index files + */ + generateAll(): { + leaderboard: LeaderboardIndex; + botDirectory: BotDirectory; + botProfiles: Map; + matchIndex: MatchIndex; + } { + const botProfiles = new Map(); + + for (const bot of this.data.bots) { + const profile = this.generateBotProfile(bot.id); + if (profile) { + botProfiles.set(bot.id, profile); + } + } + + return { + leaderboard: this.generateLeaderboard(), + botDirectory: this.generateBotDirectory(), + botProfiles, + matchIndex: this.generateMatchIndex(), + }; + } +} diff --git a/cmd/acb-indexer/src/index.ts b/cmd/acb-indexer/src/index.ts new file mode 100644 index 0000000..a6526a0 --- /dev/null +++ b/cmd/acb-indexer/src/index.ts @@ -0,0 +1,109 @@ +#!/usr/bin/env node +// AI Code Battle Index Builder +// Fetches data from Worker API and generates static JSON index files + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +import 'dotenv/config'; +import { ApiClient } from './api.js'; +import { IndexGenerator } from './generator.js'; +import { FileWriter } from './writer.js'; + +const execAsync = promisify(exec); + +interface Config { + apiUrl: string; + apiKey: string; + outputDir: string; + deployCommand?: string; +} + +function getConfig(): Config { + const apiUrl = process.env.API_URL; + const apiKey = process.env.API_KEY; + const outputDir = process.env.OUTPUT_DIR || './data'; + const deployCommand = process.env.DEPLOY_COMMAND; + + if (!apiUrl) { + console.error('ERROR: API_URL environment variable is required'); + process.exit(1); + } + + if (!apiKey) { + console.error('ERROR: API_KEY environment variable is required'); + process.exit(1); + } + + return { + apiUrl, + apiKey, + outputDir, + deployCommand, + }; +} + +async function runIndexBuilder(config: Config): Promise { + console.log('AI Code Battle Index Builder'); + console.log('============================'); + console.log(`API URL: ${config.apiUrl}`); + console.log(`Output directory: ${config.outputDir}`); + console.log(''); + + // Initialize components + const apiClient = new ApiClient({ + apiUrl: config.apiUrl, + apiKey: config.apiKey, + }); + + const fileWriter = new FileWriter(config.outputDir); + + // Step 1: Fetch data from API + console.log('Fetching data from Worker API...'); + const data = await apiClient.fetchExportData(); + console.log(` - ${data.bots.length} bots`); + console.log(` - ${data.matches.length} matches`); + console.log(` - ${data.rating_history.length} rating history entries`); + console.log(''); + + // Step 2: Generate index files + console.log('Generating index files...'); + const generator = new IndexGenerator(data); + const indexes = generator.generateAll(); + + // Step 3: Write files to disk + console.log('Writing index files...'); + await fileWriter.writeAll(indexes); + + // Step 4: Deploy (optional) + if (config.deployCommand) { + console.log('\nDeploying to Cloudflare Pages...'); + try { + const { stdout, stderr } = await execAsync(config.deployCommand, { + cwd: config.outputDir, + }); + if (stdout) console.log(stdout); + if (stderr) console.error(stderr); + console.log('Deploy complete!'); + } catch (error) { + console.error('Deploy failed:', error); + process.exit(1); + } + } +} + +async function main(): Promise { + const config = getConfig(); + + try { + await runIndexBuilder(config); + } catch (error) { + console.error('Index builder failed:', error); + process.exit(1); + } +} + +// Run if executed directly +main(); diff --git a/cmd/acb-indexer/src/types.ts b/cmd/acb-indexer/src/types.ts new file mode 100644 index 0000000..5545b26 --- /dev/null +++ b/cmd/acb-indexer/src/types.ts @@ -0,0 +1,123 @@ +// Index Builder Types + +export interface ApiClientConfig { + apiUrl: string; + apiKey: string; +} + +export interface ExportBot { + id: string; + name: string; + owner_id: string; + rating: number; + rating_deviation: number; + rating_volatility: number; + matches_played: number; + matches_won: number; + created_at: string; + updated_at: string; + health_status: string; +} + +export interface ExportMatch { + id: string; + status: string; + winner_id: string | null; + turns: number | null; + end_reason: string | null; + map_id: string; + created_at: string; + completed_at: string | null; + participants: ExportMatchParticipant[]; +} + +export interface ExportMatchParticipant { + bot_id: string; + player_index: number; + score: number; + rating_before: number; + rating_after: number | null; +} + +export interface RatingHistoryEntry { + bot_id: string; + rating: number; + rating_deviation: number; + recorded_at: string; +} + +export interface ExportData { + bots: ExportBot[]; + matches: ExportMatch[]; + rating_history: RatingHistoryEntry[]; + generated_at: string; +} + +// Generated Index Types + +export interface LeaderboardEntry { + rank: number; + bot_id: string; + name: string; + owner_id: string; + rating: number; + rating_deviation: number; + matches_played: number; + matches_won: number; + win_rate: number; + health_status: string; +} + +export interface LeaderboardIndex { + updated_at: string; + entries: LeaderboardEntry[]; +} + +export interface BotProfile { + id: string; + name: string; + owner_id: string; + rating: number; + rating_deviation: number; + rating_volatility: number; + matches_played: number; + matches_won: number; + win_rate: number; + health_status: string; + created_at: string; + updated_at: string; + rating_history: RatingHistoryEntry[]; + recent_matches: MatchSummary[]; +} + +export interface BotDirectoryEntry { + id: string; + name: string; + rating: number; + matches_played: number; + win_rate: number; +} + +export interface BotDirectory { + updated_at: string; + bots: BotDirectoryEntry[]; +} + +export interface MatchSummary { + id: string; + completed_at: string | null; + participants: { + bot_id: string; + name: string; + score: number; + won: boolean; + }[]; + winner_id: string | null; + turns: number | null; + end_reason: string | null; +} + +export interface MatchIndex { + updated_at: string; + matches: MatchSummary[]; +} diff --git a/cmd/acb-indexer/src/writer.ts b/cmd/acb-indexer/src/writer.ts new file mode 100644 index 0000000..8bf865b --- /dev/null +++ b/cmd/acb-indexer/src/writer.ts @@ -0,0 +1,111 @@ +// File Writer - Writes generated index files to disk + +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import type { LeaderboardIndex, BotDirectory, BotProfile, MatchIndex } from './types.js'; + +export class FileWriter { + private outputDir: string; + + constructor(outputDir: string) { + this.outputDir = outputDir; + } + + /** + * Ensure output directory structure exists + */ + async ensureDirectories(): Promise { + const dirs = [ + this.outputDir, + path.join(this.outputDir, 'bots'), + path.join(this.outputDir, 'matches'), + ]; + + for (const dir of dirs) { + try { + await fs.mkdir(dir, { recursive: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error; + } + } + } + } + + /** + * Write JSON file + */ + private async writeJson(filePath: string, data: unknown): Promise { + const content = JSON.stringify(data, null, 2); + await fs.writeFile(filePath, content, 'utf-8'); + console.log(`Wrote: ${filePath}`); + } + + /** + * Write leaderboard.json + */ + async writeLeaderboard(leaderboard: LeaderboardIndex): Promise { + const filePath = path.join(this.outputDir, 'leaderboard.json'); + await this.writeJson(filePath, leaderboard); + } + + /** + * Write bots/index.json + */ + async writeBotDirectory(directory: BotDirectory): Promise { + const filePath = path.join(this.outputDir, 'bots', 'index.json'); + await this.writeJson(filePath, directory); + } + + /** + * Write individual bot profile + */ + async writeBotProfile(botId: string, profile: BotProfile): Promise { + const filePath = path.join(this.outputDir, 'bots', `${botId}.json`); + await this.writeJson(filePath, profile); + } + + /** + * Write all bot profiles + */ + async writeBotProfiles(profiles: Map): Promise { + const writePromises: Promise[] = []; + + for (const [botId, profile] of profiles) { + writePromises.push(this.writeBotProfile(botId, profile)); + } + + await Promise.all(writePromises); + } + + /** + * Write matches/index.json + */ + async writeMatchIndex(matchIndex: MatchIndex): Promise { + const filePath = path.join(this.outputDir, 'matches', 'index.json'); + await this.writeJson(filePath, matchIndex); + } + + /** + * Write all index files + */ + async writeAll(data: { + leaderboard: LeaderboardIndex; + botDirectory: BotDirectory; + botProfiles: Map; + matchIndex: MatchIndex; + }): Promise { + await this.ensureDirectories(); + + await this.writeLeaderboard(data.leaderboard); + await this.writeBotDirectory(data.botDirectory); + await this.writeBotProfiles(data.botProfiles); + await this.writeMatchIndex(data.matchIndex); + + console.log(`\nIndex generation complete!`); + console.log(` - ${data.leaderboard.entries.length} leaderboard entries`); + console.log(` - ${data.botProfiles.size} bot profiles`); + console.log(` - ${data.matchIndex.matches.length} matches`); + } +} diff --git a/cmd/acb-indexer/tsconfig.json b/cmd/acb-indexer/tsconfig.json new file mode 100644 index 0000000..ddbc383 --- /dev/null +++ b/cmd/acb-indexer/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/worker-api/src/export.ts b/worker-api/src/export.ts new file mode 100644 index 0000000..b04a43a --- /dev/null +++ b/worker-api/src/export.ts @@ -0,0 +1,146 @@ +// Data Export Endpoint for Index Builder + +import type { Env, Bot, Match, MatchParticipant, ApiResponse } from './types'; + +/** + * Export data for index building. + * This endpoint is called by the Rackspace index builder every ~90 minutes. + * It returns all data needed to generate the index JSON files. + */ +export interface ExportData { + bots: ExportBot[]; + matches: ExportMatch[]; + rating_history: RatingHistoryEntry[]; + generated_at: string; +} + +export interface ExportBot { + id: string; + name: string; + owner_id: string; + rating: number; + rating_deviation: number; + rating_volatility: number; + matches_played: number; + matches_won: number; + created_at: string; + updated_at: string; + health_status: string; +} + +export interface ExportMatch { + id: string; + status: string; + winner_id: string | null; + turns: number | null; + end_reason: string | null; + map_id: string; + created_at: string; + completed_at: string | null; + participants: ExportMatchParticipant[]; +} + +export interface ExportMatchParticipant { + bot_id: string; + player_index: number; + score: number; + rating_before: number; + rating_after: number | null; +} + +export interface RatingHistoryEntry { + bot_id: string; + rating: number; + rating_deviation: number; + recorded_at: string; +} + +/** + * GET /api/data/export - Export all data for index building + */ +export async function exportData(env: Env): Promise> { + const now = new Date().toISOString(); + + // Fetch all bots + const botsResult = await env.DB.prepare( + `SELECT + id, name, owner_id, rating, rating_deviation, rating_volatility, + matches_played, matches_won, created_at, updated_at, health_status + FROM bots + ORDER BY rating DESC` + ).all(); + + // Fetch recent matches (last 1000 completed) + const matchesResult = await env.DB.prepare( + `SELECT id, status, winner_id, turns, end_reason, map_id, created_at, completed_at + FROM matches + WHERE status = 'completed' + ORDER BY completed_at DESC + LIMIT 1000` + ).all(); + + // Fetch match participants for all matches + const matchIds = matchesResult.results.map(m => m.id); + let participants: MatchParticipant[] = []; + + if (matchIds.length > 0) { + // Build query with proper parameter binding + const placeholders = matchIds.map(() => '?').join(','); + const participantsResult = await env.DB.prepare( + `SELECT bot_id, match_id, player_index, score, rating_before, rating_after + FROM match_participants + WHERE match_id IN (${placeholders})` + ).bind(...matchIds).all(); + + participants = participantsResult.results || []; + } + + // Group participants by match_id + const participantsByMatch = new Map(); + for (const p of participants) { + if (!participantsByMatch.has(p.match_id)) { + participantsByMatch.set(p.match_id, []); + } + participantsByMatch.get(p.match_id)!.push(p); + } + + // Build export matches with embedded participants + const exportMatches: ExportMatch[] = matchesResult.results.map(m => ({ + id: m.id, + status: m.status, + winner_id: m.winner_id, + turns: m.turns, + end_reason: m.end_reason, + map_id: m.map_id, + created_at: m.created_at, + completed_at: m.completed_at, + participants: (participantsByMatch.get(m.id) || []).map(p => ({ + bot_id: p.bot_id, + player_index: p.player_index, + score: p.score, + rating_before: p.rating_before, + rating_after: p.rating_after, + })), + })); + + // Fetch rating history (last 30 days) + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + const ratingHistoryResult = await env.DB.prepare( + `SELECT bot_id, rating, rating_deviation, recorded_at + FROM rating_history + WHERE recorded_at >= ? + ORDER BY bot_id, recorded_at ASC` + ) + .bind(thirtyDaysAgo) + .all(); + + return { + success: true, + data: { + bots: botsResult.results || [], + matches: exportMatches, + rating_history: ratingHistoryResult.results || [], + generated_at: now, + }, + }; +} diff --git a/worker-api/src/index.ts b/worker-api/src/index.ts index 34df940..6d7c14b 100644 --- a/worker-api/src/index.ts +++ b/worker-api/src/index.ts @@ -18,6 +18,7 @@ import { rotateApiKey, getLeaderboard, } from './bots'; +import { exportData } from './export'; export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { @@ -178,6 +179,16 @@ export default { return json(result); } + // ============ Data Export Endpoint (for index builder) ============ + + if (path === '/api/data/export' && method === 'GET') { + if (!(await verifyApiKey())) { + return json({ success: false, error: 'Unauthorized' }, 401); + } + const result = await exportData(env); + return json(result); + } + // 404 for unmatched routes return json({ success: false, error: 'Not found' }, 404); } catch (error) {