ai-code-battle/web/src/api-types.ts
jedarden 347ae4f1df feat(predictions): resolve predictions on match completion, add API endpoints and frontend
- Worker resolves open predictions after writing match results (resolvePredictions + upsertPredictorStats)
- API endpoints: POST /api/predict, GET /api/predictions/open, GET /api/predictions/history
- Frontend /watch/predictions page with polling, prediction submission, and history display
- predictor_stats table tracks streaks and accuracy per predictor
- Series format selection: fix threshold from >200 to >=200 for bo3 eligibility

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 14:08:15 -04:00

566 lines
15 KiB
TypeScript

// API response types matching the Worker API and index builder
// Leaderboard 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[];
}
// Bot profile types
export interface RatingHistoryEntry {
bot_id: string;
rating: number;
rating_deviation: number;
recorded_at: string;
}
export interface MatchSummaryParticipant {
bot_id: string;
name: string;
score: number;
won: boolean;
}
export interface MatchSummary {
id: string;
completed_at: string | null;
participants: MatchSummaryParticipant[];
winner_id: string | null;
turns: number | null;
end_reason: string | null;
}
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[];
// Evolution fields (optional - only present for evolved bots)
evolved?: boolean;
island?: string;
generation?: number;
parent_ids?: string[];
}
export interface BotDirectoryEntry {
id: string;
name: string;
rating: number;
matches_played: number;
win_rate: number;
}
export interface BotDirectory {
updated_at: string;
bots: BotDirectoryEntry[];
}
// Match index types
export interface MatchIndex {
updated_at: string;
matches: MatchSummary[];
}
// Registration types
export interface RegisterRequest {
name: string;
endpoint_url: string;
owner_id: string;
}
export interface RegisterResponse {
success: boolean;
bot_id?: string;
api_key?: string;
error?: string;
}
// Evolution dashboard types
// Dashboard island stat (live.json format)
export interface IslandStat {
population: number;
best_rating: number;
best_bot: string;
language_div?: string;
}
// Full island stat (legacy format)
export interface IslandStatFull {
count: number;
best_fitness: number;
avg_fitness: number;
diversity: number;
promoted_count: number;
}
// Parent info for candidate
export interface ParentInfo {
id: string;
rating: number;
}
// Validation stage result
export interface StageResult {
passed: boolean;
time_ms: number;
error?: string;
}
// Validation status
export interface ValidationStatus {
syntax?: StageResult;
schema?: StageResult;
smoke?: StageResult;
}
// Match result in evaluation
export interface MatchResult {
opponent: string;
won: boolean;
score: string;
}
// Evaluation status
export interface EvaluationStatus {
matches_total: number;
matches_played: number;
results: MatchResult[];
}
// Current candidate being evaluated
export interface Candidate {
id: string;
island: string;
language: string;
parents: ParentInfo[];
validation?: ValidationStatus;
evaluation?: EvaluationStatus;
}
// Current cycle info
export interface CycleInfo {
generation: number;
started_at: string;
phase: string; // generating, validating, evaluating, promoting, idle
candidate?: Candidate;
}
// Activity entry in recent activity feed
export interface ActivityEntry {
time: string;
generation: number;
candidate: string;
island: string;
result: string; // promoted, rejected
reason: string;
stage: string; // validation, promotion, deployment
bot_id?: string;
initial_rating?: number;
}
// Overall evolution statistics
export interface Totals {
generations_total: number;
candidates_today: number;
promoted_today: number;
promotion_rate_7d: number;
highest_evolved_rating: number;
evolved_in_top_10: number;
}
// Legacy generation entry
export interface GenerationEntry {
generation: number;
island: string;
evaluated_at: string;
count: number;
promoted: number;
best_fitness: number;
avg_fitness: number;
}
// Legacy lineage node
export interface LineageNode {
id: number;
parent_ids: number[];
generation: number;
island: string;
fitness: number;
promoted: boolean;
language: string;
created_at: string;
}
// Legacy meta snapshot
export interface MetaSnapshot {
generation: number;
island_counts: Record<string, number>;
island_best_fitness: Record<string, number>;
}
// Evolution live data (plan §14 format)
export interface EvolutionLiveData {
updated_at: string;
cycle?: CycleInfo;
recent_activity?: ActivityEntry[];
islands: Record<string, IslandStat>;
totals: Totals;
// Legacy fields for backward compatibility
total_programs?: number;
promoted_count?: number;
generation_log?: GenerationEntry[];
lineage?: LineageNode[];
meta_snapshots?: MetaSnapshot[];
}
// Blog / Narrative Engine types
export interface BlogWeekStats {
matches_played: number;
top_bot: string;
top_bot_rating: number;
biggest_upset: string | null;
most_active_bot: string;
most_active_bot_matches: number;
island_leader: string | null;
}
export interface BlogPost {
slug: string;
title: string;
published_at: string;
week_start: string;
summary: string;
body_html: string;
stats: BlogWeekStats;
}
export interface BlogIndex {
updated_at: string;
posts: BlogPost[];
}
// API configuration
export const API_BASE = '/api';
// ─── Stale-while-revalidate cache ─────────────────────────────────────────────────
// Returns cached data instantly (if available) while fetching fresh data in the
// background. On the next call the fresh data is already in cache. This gives
// sub-ms render times for repeat visits while keeping data reasonably current.
interface CacheEntry<T> { data: T; ts: number }
const swrCache = new Map<string, CacheEntry<unknown>>();
const SWR_MAX_AGE = 5 * 60 * 1000; // 5 min — data is served from cache without re-fetch
function swr<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const cached = swrCache.get(key) as CacheEntry<T> | undefined;
if (cached) {
// Serve stale immediately, revalidate in background if older than max-age
if (Date.now() - cached.ts > SWR_MAX_AGE) {
fetcher().then(data => swrCache.set(key, { data, ts: Date.now() })).catch(() => {});
}
return Promise.resolve(cached.data);
}
// No cache — fetch and cache
return fetcher().then(data => {
swrCache.set(key, { data, ts: Date.now() });
return data;
});
}
// API client functions
export async function fetchLeaderboard(): Promise<LeaderboardIndex> {
return swr('leaderboard', async () => {
const response = await fetch('/data/leaderboard.json');
if (!response.ok) throw new Error(`Failed to fetch leaderboard: ${response.status}`);
return response.json();
});
}
export async function fetchBotDirectory(): Promise<BotDirectory> {
return swr('bot-directory', async () => {
const response = await fetch('/data/bots/index.json');
if (!response.ok) throw new Error(`Failed to fetch bot directory: ${response.status}`);
return response.json();
});
}
export async function fetchBotProfile(botId: string): Promise<BotProfile> {
return swr(`bot-${botId}`, async () => {
const response = await fetch(`/data/bots/${botId}.json`);
if (!response.ok) throw new Error(`Failed to fetch bot profile: ${response.status}`);
return response.json();
});
}
export async function fetchMatchIndex(): Promise<MatchIndex> {
return swr('match-index', async () => {
const response = await fetch('/data/matches/index.json');
if (!response.ok) throw new Error(`Failed to fetch match index: ${response.status}`);
return response.json();
});
}
export async function registerBot(request: RegisterRequest): Promise<RegisterResponse> {
const response = await fetch(`${API_BASE}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
return response.json();
}
// R2_BASE_URL is the Cloudflare R2 bucket custom domain for live data.
// The evolver writes live.json here every cycle with Cache-Control: max-age=10.
const R2_BASE_URL = 'https://r2.aicodebattle.com';
export async function fetchEvolutionData(): Promise<EvolutionLiveData> {
// Evolution data changes every ~10s — bypass SWR, always fetch fresh
const response = await fetch(`${R2_BASE_URL}/evolution/live.json`);
if (!response.ok) throw new Error(`Failed to fetch evolution data: ${response.status}`);
return response.json();
}
export async function fetchBlogIndex(): Promise<BlogIndex> {
return swr('blog-index', async () => {
const response = await fetch('/data/blog/index.json');
if (!response.ok) throw new Error(`Failed to fetch blog index: ${response.status}`);
return response.json();
});
}
export async function fetchBlogPost(slug: string): Promise<BlogPost> {
return swr(`blog-${slug}`, async () => {
const response = await fetch(`/data/blog/${slug}.json`);
if (!response.ok) throw new Error(`Failed to fetch blog post: ${response.status}`);
return response.json();
});
}
export async function rotateApiKey(botId: string, currentKey: string): Promise<RegisterResponse> {
const response = await fetch(`${API_BASE}/rotate-key`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${currentKey}`,
},
body: JSON.stringify({ bot_id: botId }),
});
return response.json();
}
// Playlist types
export type PlaylistCategory =
| 'featured'
| 'rivalry'
| 'upsets'
| 'comebacks'
| 'domination'
| 'close_games'
| 'long_games'
| 'tutorial'
| 'season'
| 'weekly';
export interface PlaylistMatch {
match_id: string;
order: number;
title?: string;
thumbnail_url?: string;
}
export interface Playlist {
slug: string;
title: string;
description: string;
category: PlaylistCategory;
match_count: number;
created_at: string;
updated_at: string;
matches: PlaylistMatch[];
}
export interface PlaylistSummary {
slug: string;
title: string;
description: string;
category: PlaylistCategory;
match_count: number;
updated_at: string;
thumbnail_match_id?: string;
}
export interface PlaylistIndex {
updated_at: string;
playlists: PlaylistSummary[];
}
export async function fetchPlaylistIndex(): Promise<PlaylistIndex> {
return swr('playlist-index', async () => {
const response = await fetch('/data/playlists/index.json');
if (!response.ok) throw new Error(`Failed to fetch playlist index: ${response.status}`);
return response.json();
});
}
export async function fetchPlaylist(slug: string): Promise<Playlist> {
return swr(`playlist-${slug}`, async () => {
const response = await fetch(`/data/playlists/${slug}.json`);
if (!response.ok) throw new Error(`Failed to fetch playlist: ${response.status}`);
return response.json();
});
}
// Prediction types
export interface PredictionData {
id: number;
match_id: string;
predictor_id: string;
predicted_bot: string;
correct?: boolean;
created_at: string;
resolved_at?: string;
}
export interface PredictionHistoryEntry {
id: number;
match_id: string;
predicted_bot: string;
predicted_name: string;
correct: boolean | null;
confidence?: number;
created_at: string;
resolved_at?: string;
match_status: string;
winner_name?: string;
}
export async function fetchPredictionHistory(predictorId: string, limit?: number): Promise<{ predictions: PredictionHistoryEntry[] }> {
const params = new URLSearchParams({ predictor_id: predictorId });
if (limit) params.set('limit', String(limit));
const response = await fetch(`/api/predictions/history?${params}`);
if (!response.ok) throw new Error(`Failed to fetch prediction history: ${response.status}`);
return response.json();
}
export interface PredictorStats {
predictor_id: string;
correct: number;
incorrect: number;
streak: number;
best_streak: number;
}
export interface PredictionsLeaderboard {
updated_at: string;
entries: PredictorStats[];
}
export async function fetchPredictionsLeaderboard(): Promise<PredictionsLeaderboard> {
return swr('predictions-leaderboard', async () => {
const response = await fetch('/data/predictions/leaderboard.json');
if (!response.ok) throw new Error(`Failed to fetch predictions leaderboard: ${response.status}`);
return response.json();
});
}
export interface OpenMatch {
match_id: string;
created_at: string;
participants: { bot_id: string; name: string; rating: number }[];
your_pick?: string;
}
export interface OpenPredictionsResponse {
matches: OpenMatch[];
}
export async function fetchOpenPredictions(predictorId?: string): Promise<OpenPredictionsResponse> {
const params = predictorId ? `?predictor_id=${encodeURIComponent(predictorId)}` : '';
const response = await fetch(`/api/predictions/open${params}`);
if (!response.ok) throw new Error(`Failed to fetch open predictions: ${response.status}`);
return response.json();
}
export async function submitPrediction(matchId: string, botId: string, predictorId: string): Promise<{ id: number }> {
const response = await fetch('/api/predict', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ match_id: matchId, bot_id: botId, predictor_id: predictorId }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(err.error || `Failed to submit prediction: ${response.status}`);
}
return response.json();
}
export function getOrCreatePredictorId(): string {
let id = localStorage.getItem('acb_predictor_id');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('acb_predictor_id', id);
}
return id;
}
// Evolution meta types for homepage
export interface EvolutionMeta {
generation: number;
promoted_today: number;
top_10_count: number;
updated_at: string;
}
export async function fetchEvolutionMeta(): Promise<EvolutionMeta> {
return swr('evolution-meta', async () => {
const response = await fetch('/data/evolution/meta.json');
if (!response.ok) {
return { generation: 0, promoted_today: 0, top_10_count: 0, updated_at: '' };
}
return response.json();
});
}
// Season types (re-export from types.ts for convenience)
import type { SeasonIndex } from './types';
export type { Season, SeasonIndex } from './types';
export async function fetchSeasonIndex(): Promise<SeasonIndex> {
return swr('season-index', async () => {
const response = await fetch('/data/seasons/index.json');
if (!response.ok) {
return { updated_at: '', active_season: null, seasons: [] };
}
return response.json();
});
}