ai-code-battle/web/src/api-types.ts
jedarden ae0f072f80 feat(web): route R2 assets through Pages Function instead of r2.aicodebattle.com
The aicodebattle.com domain was never registered. Replace all hardcoded
https://r2.aicodebattle.com references with the /r2/ relative path, served
by a Cloudflare Pages Function that reads from the acb-data R2 bucket via
binding. Adds web/functions/r2/[[path]].ts and web/wrangler.toml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 07:55:44 -04:00

607 lines
16 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;
map_id?: string;
turns: number | null;
end_reason: string | null;
enriched?: boolean;
}
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[];
// §14.1: debug telemetry public-visibility toggle
debug_public?: boolean;
}
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;
debug_public?: boolean;
}
export interface RegisterResponse {
success: boolean;
bot_id?: string;
api_key?: string;
error?: string;
}
// Evolution dashboard types (re-exported from types.ts for convenience)
export type {
LiveJSON,
EvolutionIslandStat as IslandStat,
EvolutionParentInfo as ParentInfo,
EvolutionStageResult as StageResult,
EvolutionValidationStatus as ValidationStatus,
EvaluationMatchResult as MatchResult,
EvolutionEvaluationStatus as EvaluationStatus,
EvolutionCandidate as Candidate,
EvolutionCycleInfo as CycleInfo,
EvolutionActivityEntry as ActivityEntry,
EvolutionTotals as Totals,
EvolutionGenerationEntry as GenerationEntry,
EvolutionLineageNode as LineageNode,
EvolutionMetaSnapshot as MetaSnapshot,
} from './types';
// Convenience alias: the full live.json document
import type { LiveJSON } from './types';
export type EvolutionLiveData = LiveJSON;
// Full island stat (legacy format, not in live.json schema)
export interface IslandStatFull {
count: number;
best_fitness: number;
avg_fitness: number;
diversity: number;
promoted_count: number;
}
// 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;
type: 'meta-report' | 'chronicle';
summary: string;
body_markdown: string;
tags: string[];
}
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;
});
}
/** Seed the SWR cache with pre-fetched data (used by hover preloader). */
export function seedSwrCache(key: string, data: unknown): void {
if (!swrCache.has(key)) {
swrCache.set(key, { data, ts: Date.now() });
}
}
// 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 = '/r2';
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;
curation_tag?: string;
participants?: { bot_id: string; name: string; score: number; won: boolean }[];
score?: string;
turns?: number;
end_reason?: string;
completed_at?: 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();
});
}
// Commentary / Enrichment types (§13.3)
export interface CommentaryEntry {
turn: number;
text: string;
type: 'setup' | 'action' | 'reaction' | 'climax' | 'denouement';
}
export interface EnrichedCommentary {
match_id: string;
generated_at: string;
criteria: string[];
entries: CommentaryEntry[];
}
export interface EnrichedMatchEntry {
match_id: string;
criteria: string[];
}
export interface EnrichedIndex {
updated_at: string;
entries: EnrichedMatchEntry[];
}
const R2_COMMENTARY_BASE = '/r2';
export async function fetchCommentary(matchId: string): Promise<EnrichedCommentary | null> {
try {
const response = await fetch(`${R2_COMMENTARY_BASE}/commentary/${matchId}.json`);
if (!response.ok) return null;
return response.json();
} catch {
return null;
}
}
export async function fetchEnrichedIndex(): Promise<EnrichedIndex> {
return swr('enriched-index', async () => {
const response = await fetch('/data/commentary/index.json');
if (!response.ok) return { updated_at: '', entries: [] };
return response.json();
});
}
// Rivalry types (matches data/meta/rivalries.json from index builder §13.5)
export interface RivalryBot {
id: string;
name: string;
}
export interface RivalryRecord {
a_wins: number;
b_wins: number;
draws: number;
}
export interface RivalryStreak {
holder: string;
length: number;
}
export interface RivalryEntry {
bot_a: RivalryBot;
bot_b: RivalryBot;
matches: number;
record: RivalryRecord;
closest_match?: string;
longest_streak?: RivalryStreak;
recent_matches: string[];
narrative: string;
score: number;
}
export interface RivalriesIndex {
updated_at: string;
rivalries: RivalryEntry[];
}
export async function fetchRivalries(): Promise<RivalriesIndex> {
return swr('rivalries', async () => {
const response = await fetch('/data/meta/rivalries.json');
if (!response.ok) return { updated_at: '', rivalries: [] };
return response.json();
});
}
// Map voting types (§14.6)
export interface MapVoteResponse {
map_id: string;
vote: number;
net_votes: number;
}
export interface MapVotesResponse {
map_id: string;
net_votes: number;
my_vote?: number;
}
export function getOrCreateVoterId(): string {
let id = localStorage.getItem('acb_voter_id');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('acb_voter_id', id);
}
return id;
}
export async function submitMapVote(mapId: string, vote: 1 | -1): Promise<MapVoteResponse> {
const voterId = getOrCreateVoterId();
const response = await fetch(`${API_BASE}/vote/map`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ map_id: mapId, voter_id: voterId, vote }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(err.error || `Failed to submit vote: ${response.status}`);
}
return response.json();
}
export async function fetchMapVotes(mapId: string): Promise<MapVotesResponse> {
const voterId = getOrCreateVoterId();
const response = await fetch(`${API_BASE}/vote/map/${encodeURIComponent(mapId)}?voter_id=${encodeURIComponent(voterId)}`);
if (!response.ok) throw new Error(`Failed to fetch map votes: ${response.status}`);
return response.json();
}