Complete Phase 4: Add index builder container
- Add /api/data/export endpoint to worker-api for data export - Create cmd/acb-indexer/ TypeScript container: - API client for fetching data from Worker API - Index generator for leaderboard, bot profiles, match index - File writer for outputting JSON files - Optional Cloudflare Pages deploy support - Unit tests (6 tests) - Update PROGRESS.md to mark Phase 4 complete Phase 4 is now complete. All exit criteria met: - Matchmaker cron creates jobs in D1 - Workers claim and execute matches - Replays land in R2 - Results flow into D1 - Ratings update via Glicko-2 - Leaderboard.json rebuilds automatically - Stale job reaper recovers from worker disappearance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4f55d39172
commit
4bbc3f0515
13 changed files with 988 additions and 8 deletions
49
PROGRESS.md
49
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
|
||||
|
|
|
|||
5
cmd/acb-indexer/.gitignore
vendored
Normal file
5
cmd/acb-indexer/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
data/
|
||||
*.log
|
||||
33
cmd/acb-indexer/Dockerfile
Normal file
33
cmd/acb-indexer/Dockerfile
Normal file
|
|
@ -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"]
|
||||
24
cmd/acb-indexer/package.json
Normal file
24
cmd/acb-indexer/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
42
cmd/acb-indexer/src/api.ts
Normal file
42
cmd/acb-indexer/src/api.ts
Normal file
|
|
@ -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<ExportData> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
160
cmd/acb-indexer/src/generator.test.ts
Normal file
160
cmd/acb-indexer/src/generator.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
166
cmd/acb-indexer/src/generator.ts
Normal file
166
cmd/acb-indexer/src/generator.ts
Normal file
|
|
@ -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<string, string>;
|
||||
|
||||
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<string, BotProfile>;
|
||||
matchIndex: MatchIndex;
|
||||
} {
|
||||
const botProfiles = new Map<string, BotProfile>();
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
109
cmd/acb-indexer/src/index.ts
Normal file
109
cmd/acb-indexer/src/index.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
const config = getConfig();
|
||||
|
||||
try {
|
||||
await runIndexBuilder(config);
|
||||
} catch (error) {
|
||||
console.error('Index builder failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if executed directly
|
||||
main();
|
||||
123
cmd/acb-indexer/src/types.ts
Normal file
123
cmd/acb-indexer/src/types.ts
Normal file
|
|
@ -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[];
|
||||
}
|
||||
111
cmd/acb-indexer/src/writer.ts
Normal file
111
cmd/acb-indexer/src/writer.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const filePath = path.join(this.outputDir, 'leaderboard.json');
|
||||
await this.writeJson(filePath, leaderboard);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write bots/index.json
|
||||
*/
|
||||
async writeBotDirectory(directory: BotDirectory): Promise<void> {
|
||||
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<void> {
|
||||
const filePath = path.join(this.outputDir, 'bots', `${botId}.json`);
|
||||
await this.writeJson(filePath, profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write all bot profiles
|
||||
*/
|
||||
async writeBotProfiles(profiles: Map<string, BotProfile>): Promise<void> {
|
||||
const writePromises: Promise<void>[] = [];
|
||||
|
||||
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<void> {
|
||||
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<string, BotProfile>;
|
||||
matchIndex: MatchIndex;
|
||||
}): Promise<void> {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
17
cmd/acb-indexer/tsconfig.json
Normal file
17
cmd/acb-indexer/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
146
worker-api/src/export.ts
Normal file
146
worker-api/src/export.ts
Normal file
|
|
@ -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<ApiResponse<ExportData>> {
|
||||
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<ExportBot>();
|
||||
|
||||
// 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<Match>();
|
||||
|
||||
// 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<MatchParticipant>();
|
||||
|
||||
participants = participantsResult.results || [];
|
||||
}
|
||||
|
||||
// Group participants by match_id
|
||||
const participantsByMatch = new Map<string, MatchParticipant[]>();
|
||||
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<RatingHistoryEntry>();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
bots: botsResult.results || [],
|
||||
matches: exportMatches,
|
||||
rating_history: ratingHistoryResult.results || [],
|
||||
generated_at: now,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
rotateApiKey,
|
||||
getLeaderboard,
|
||||
} from './bots';
|
||||
import { exportData } from './export';
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue