diff --git a/PROGRESS.md b/PROGRESS.md index cba8e47..55b0a0e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,152 +1,58 @@ # AI Code Battle - Implementation Progress -## Current Phase: Phase 4 - Match Orchestration +## Current Phase: Phase 5 - Web Platform -**Status: ✅ COMPLETE** +**Status: 🔄 In Progress** -### Phase 4 Progress +### Phase 5 Progress -- [x] Cloudflare Worker project structure (`worker-api/`) - - TypeScript + Wrangler configuration - - D1 database schema (bots, matches, jobs, rating_history tables) -- [x] Glicko-2 rating system (`worker-api/src/glicko2.ts`) - - Rating scale conversion - - Rating updates after matches - - Rating decay for inactive bots - - Unit tests (17 tests) -- [x] Job coordination endpoints (`worker-api/src/jobs.ts`) - - GET /api/jobs/next - Get next pending job - - POST /api/jobs/:id/claim - Claim job for execution - - POST /api/jobs/:id/heartbeat - Update job heartbeat - - POST /api/jobs/:id/result - Submit match result - - POST /api/jobs/:id/fail - Mark job as failed -- [x] Bot management endpoints (`worker-api/src/bots.ts`) - - POST /api/register - Register new bot - - GET /api/bots - List all bots - - GET /api/bots/:id - Get bot details - - PUT /api/bots/:id - Update bot - - POST /api/rotate-key - Rotate API key - - GET /api/leaderboard - Get leaderboard -- [x] 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 - - Stale job reaper (every 5 min) - Reclaims timed-out jobs -- [x] Match worker container (`cmd/acb-worker/`) - - Polls Worker API for pending jobs - - Claims jobs and executes matches using game engine - - Uploads replays to R2 via S3-compatible API - - Sends heartbeats during match execution - - Submits results back to Worker API - - Retry logic with exponential backoff - - API client tests (10 tests) -- [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) +- [x] SPA application shell (`web/app.html`) + - Navigation header with links to all sections + - Dark theme with CSS custom properties + - Responsive layout +- [x] Hash-based router (`web/src/router.ts`) + - Pattern matching with parameter extraction + - Navigation and history support +- [x] Page components (`web/src/pages/`) + - Home page with hero, features, quick links + - Leaderboard with ranking table + - Match history with match cards + - Bot directory with bot cards + - Bot profile with stats, rating chart, recent matches + - Registration form with API key display + - Replay viewer (integrated from Phase 3) + - Docs/Getting Started page +- [x] API client (`web/src/api-types.ts`) + - fetchLeaderboard() + - fetchBotDirectory() + - fetchBotProfile() + - fetchMatchIndex() + - registerBot() + - rotateApiKey() +- [ ] Cloudflare Pages deployment configuration +- [ ] R2 bucket custom domain for replays + +### Phase 4 Completed ### Phase 3 Completed -### Phase 1 Completed - -- [x] Go module initialization (`github.com/aicodebattle/acb`) -- [x] Project structure (`engine/`, `cmd/acb-local/`, `cmd/acb-mapgen/`) -- [x] Core types (`engine/types.go`) -- [x] Grid implementation (`engine/grid.go`) - Toroidal wrapping, distances, visibility -- [x] Game state (`engine/game.go`) - State management, fog of war -- [x] Turn execution (`engine/turn.go`) - Movement, combat, capture, energy, spawn -- [x] Replay writer (`engine/replay.go`) - Full replay JSON format -- [x] Match runner (`engine/match.go`) - Concurrent bot communication -- [x] Map generator (`cmd/acb-mapgen/`) - Rotational symmetry, connectivity validation -- [x] Unit tests - 32+ tests passing, determinism verified - ### Phase 2 Completed -- [x] HMAC Authentication (`engine/auth.go`) - - Request signing: `{match_id}.{turn}.{timestamp}.{sha256(body)}` - - Response signing: `{match_id}.{turn}.{sha256(body)}` - - Timestamp tolerance (30s) for replay attack prevention - - Secret generation (256-bit, hex-encoded) -- [x] HTTP Bot Client (`engine/bot_http.go`) - - HTTPBot implementing BotInterface - - Per-turn timeout (3s default) - - Crash detection (10 consecutive failures) - - Move validation (position ownership, direction validity) - - Response signature verification -- [x] Integration Tests (`engine/integration_test.go`) - - Full HTTP match between mock bots - - HMAC authentication round-trip - - Response signing verification -- [x] Strategy Bot Implementations (6 languages) - - **RandomBot** (Python) - Random moves, rating floor - - **GathererBot** (Go) - Energy-focused, combat avoidance - - **RusherBot** (Rust) - Aggressive core rushing - - **GuardianBot** (PHP) - Defensive core protection - - **SwarmBot** (TypeScript) - Formation-based combat - - **HunterBot** (Java) - Target isolation and hunting - -### Phase 3 Completed - -- [x] Web project setup (`web/`) - - TypeScript + Vite build tooling - - Type definitions matching Go replay format -- [x] ReplayViewer class (`web/src/replay-viewer.ts`) - - Canvas-based grid rendering - - Bot, core, energy, wall visualization - - Player color coding (6 distinct colors) -- [x] Playback controls - - Play/pause toggle - - Turn-by-step navigation (prev/next) - - Turn scrubber slider - - Speed control (20ms - 1000ms per turn) - - Keyboard shortcuts (Space, arrows, Home/End) -- [x] Fog of War perspective toggle - - Per-player visibility calculation - - Vision radius from game config -- [x] Score overlay - - Real-time scores per player - - Energy held display - - Player name with color indicator -- [x] Match info panel - - Match ID, winner, turns, reason -- [x] Event log - - Turn-by-turn event display -- [x] File/URL loading - - Local file upload - - Remote URL fetch - -### Exit Criteria Progress +### Phase 5 Exit Criteria | Criterion | Status | |-----------|--------| -| TypeScript Canvas-based replay viewer | ✅ Complete | -| Play/pause, scrub, speed control | ✅ Complete | -| Fog of war perspective toggle | ✅ Complete | -| Score overlay | ✅ Complete | -| Loads replay JSON from file or URL | ✅ Complete | +| SPA with navigation (leaderboard, matches, bots, register) | ✅ Complete | +| Home page with getting started info | ✅ Complete | +| Registration form with API key display | ✅ Complete | +| Bot profiles with rating history chart | ✅ Complete | +| Match history page | ✅ Complete | +| Leaderboard with rankings | ✅ Complete | +| Getting started / docs page | ✅ Complete | +| Cloudflare Pages deployment config | ⏳ Pending | +| R2 bucket custom domain for replays | ⏳ Pending | -### 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** +### Phase 1 Completed ## File Structure @@ -197,11 +103,22 @@ ai-code-battle/ │ ├── package.json # npm dependencies │ ├── tsconfig.json # TypeScript config │ ├── vite.config.ts # Vite bundler config -│ ├── index.html # Replay viewer page +│ ├── index.html # Standalone replay viewer +│ ├── app.html # SPA shell with navigation │ └── src/ │ ├── types.ts # Replay type definitions +│ ├── api-types.ts # API client and types +│ ├── router.ts # Hash-based SPA router │ ├── replay-viewer.ts # Canvas viewer class -│ └── main.ts # UI controller +│ ├── main.ts # Standalone replay viewer +│ ├── app.ts # SPA entry point +│ └── pages/ # SPA page components +│ ├── home.ts +│ ├── leaderboard.ts +│ ├── matches.ts +│ ├── bots.ts +│ ├── bot-profile.ts +│ └── register.ts ├── bots/ │ ├── random/ # Python - RandomBot │ ├── gatherer/ # Go - GathererBot @@ -253,5 +170,6 @@ go build ./cmd/acb-mapgen ```bash cd web npm run dev -# Open http://localhost:3000 and load replay.json +# Standalone viewer: http://localhost:3000/index.html +# Full SPA: http://localhost:3000/app.html (then go to #/replay) ``` diff --git a/web/app.html b/web/app.html new file mode 100644 index 0000000..603f678 --- /dev/null +++ b/web/app.html @@ -0,0 +1,689 @@ + + + + + + AI Code Battle + + + + +
+ + + diff --git a/web/src/api-types.ts b/web/src/api-types.ts new file mode 100644 index 0000000..c45fa20 --- /dev/null +++ b/web/src/api-types.ts @@ -0,0 +1,143 @@ +// 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[]; +} + +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; +} + +// API configuration +export const API_BASE = '/api'; + +// API client functions +export async function fetchLeaderboard(): Promise { + 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 { + 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 { + 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 { + 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 { + const response = await fetch(`${API_BASE}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + return response.json(); +} + +export async function rotateApiKey(botId: string, currentKey: string): Promise { + 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(); +} diff --git a/web/src/app.ts b/web/src/app.ts new file mode 100644 index 0000000..5f10e72 --- /dev/null +++ b/web/src/app.ts @@ -0,0 +1,691 @@ +// Main SPA entry point with routing +import { router } from './router'; +import { renderHomePage } from './pages/home'; +import { renderLeaderboardPage } from './pages/leaderboard'; +import { renderMatchesPage } from './pages/matches'; +import { renderBotsPage } from './pages/bots'; +import { renderBotProfilePage } from './pages/bot-profile'; +import { renderRegisterPage } from './pages/register'; +import { ReplayViewer } from './replay-viewer'; +import type { Replay } from './types'; + +// Route definitions +router + .on('/', renderHomePage) + .on('/leaderboard', renderLeaderboardPage) + .on('/matches', renderMatchesPage) + .on('/bots', renderBotsPage) + .on('/bot/:id', renderBotProfilePage) + .on('/register', renderRegisterPage) + .on('/replay', renderReplayPage) + .on('/docs', renderDocsPage) + .notFound(renderNotFoundPage); + +// Update active nav link on route change +function updateActiveNavLink(): void { + const currentPath = router.getCurrentPath(); + document.querySelectorAll('.nav-link').forEach(link => { + const href = link.getAttribute('href'); + if (href) { + const linkPath = href.slice(2); // Remove '#/' + if (currentPath === linkPath || (linkPath !== '' && currentPath.startsWith(linkPath))) { + link.classList.add('active'); + } else { + link.classList.remove('active'); + } + } + }); +} + +// Override router navigation to update nav links +const originalNavigate = router.navigate.bind(router); +router.navigate = (path: string) => { + originalNavigate(path); + updateActiveNavLink(); +}; + +// Replay viewer page +function renderReplayPage(params: Record): void { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+

Replay Viewer

+ +
+
+
+ +
Load a replay file to view
+
+
+ +
+
+

Load Replay

+
+
+ + +
+
+ + +
+
+
+ +
+

Playback

+
+ + + + +
+
+ + +
+
+ + +
+
+ +
+

View Options

+
+ + + + +
+
+ +
+

Match Info

+
+
Match ID
+
-
+
Winner
+
-
+
Turns
+
-
+
Reason
+
-
+
+
+ +
+

Events This Turn

+
+
No events
+
+
+ +
+ Space Play/Pause + Step + HomeEnd First/Last +
+
+
+
+ + + `; + + // Initialize replay viewer + initReplayViewer(params.url); +} + +function initReplayViewer(initialUrl?: string): void { + const canvas = document.getElementById('replay-canvas') as HTMLCanvasElement; + const noReplayDiv = document.getElementById('no-replay') as HTMLDivElement; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const urlInput = document.getElementById('url-input') as HTMLInputElement; + const loadUrlBtn = document.getElementById('load-url-btn') as HTMLButtonElement; + const playBtn = document.getElementById('play-btn') as HTMLButtonElement; + const prevBtn = document.getElementById('prev-btn') as HTMLButtonElement; + const nextBtn = document.getElementById('next-btn') as HTMLButtonElement; + const resetBtn = document.getElementById('reset-btn') as HTMLButtonElement; + const turnDisplay = document.getElementById('turn-display') as HTMLSpanElement; + const totalTurnsSpan = document.getElementById('total-turns') as HTMLSpanElement; + const turnSlider = document.getElementById('turn-slider') as HTMLInputElement; + const speedDisplay = document.getElementById('speed-display') as HTMLSpanElement; + const speedSlider = document.getElementById('speed-slider') as HTMLInputElement; + const fogSelect = document.getElementById('fog-select') as HTMLSelectElement; + const cellSizeSelect = document.getElementById('cell-size-select') as HTMLSelectElement; + const eventLogDiv = document.getElementById('event-log') as HTMLDivElement; + const infoMatchId = document.getElementById('info-match-id') as HTMLElement; + const infoWinner = document.getElementById('info-winner') as HTMLElement; + const infoTurns = document.getElementById('info-turns') as HTMLElement; + const infoReason = document.getElementById('info-reason') as HTMLElement; + + let viewer = new ReplayViewer(canvas, { cellSize: 10 }); + + function enableControls(): void { + playBtn.disabled = false; + prevBtn.disabled = false; + nextBtn.disabled = false; + resetBtn.disabled = false; + turnSlider.disabled = false; + noReplayDiv.style.display = 'none'; + } + + function updateUI(): void { + turnDisplay.textContent = String(viewer.getTurn()); + totalTurnsSpan.textContent = String(viewer.getTotalTurns()); + turnSlider.value = String(viewer.getTurn()); + playBtn.textContent = 'Pause'; + if (!viewer.getReplay() || viewer.isAtEnd()) { + playBtn.textContent = 'Play'; + } + } + + function updateEventLog(): void { + const events = viewer.getTurnEvents(); + if (events.length === 0) { + eventLogDiv.innerHTML = '
No events
'; + return; + } + eventLogDiv.innerHTML = events.map(e => { + const type = e.type.replace(/_/g, ' '); + return `
${type}
`; + }).join(''); + } + + function updateMatchInfo(replay: Replay): void { + infoMatchId.textContent = replay.match_id; + infoTurns.textContent = String(replay.result.turns); + infoReason.textContent = replay.result.reason; + + if (replay.result.winner >= 0 && replay.result.winner < replay.players.length) { + infoWinner.textContent = replay.players[replay.result.winner].name; + } else if (replay.result.winner === -1) { + infoWinner.textContent = 'Draw'; + } else { + infoWinner.textContent = 'Player ' + replay.result.winner; + } + + fogSelect.innerHTML = ''; + replay.players.forEach((player, idx) => { + const option = document.createElement('option'); + option.value = String(idx); + option.textContent = player.name; + fogSelect.appendChild(option); + }); + } + + function loadReplay(replay: Replay): void { + viewer.loadReplay(replay); + enableControls(); + updateMatchInfo(replay); + turnSlider.max = String(viewer.getTotalTurns() - 1); + updateUI(); + updateEventLog(); + } + + fileInput.addEventListener('change', async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + try { + const text = await file.text(); + const replay = JSON.parse(text) as Replay; + loadReplay(replay); + } catch (err) { + alert('Failed to load replay: ' + err); + } + }); + + loadUrlBtn.addEventListener('click', async () => { + const url = urlInput.value.trim(); + if (!url) return; + try { + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const replay = await response.json() as Replay; + loadReplay(replay); + } catch (err) { + alert('Failed to load replay from URL: ' + err); + } + }); + + playBtn.addEventListener('click', () => viewer.togglePlay()); + prevBtn.addEventListener('click', () => { viewer.setTurn(viewer.getTurn() - 1); updateUI(); updateEventLog(); }); + nextBtn.addEventListener('click', () => { viewer.setTurn(viewer.getTurn() + 1); updateUI(); updateEventLog(); }); + resetBtn.addEventListener('click', () => { viewer.pause(); viewer.setTurn(0); updateUI(); updateEventLog(); }); + + turnSlider.addEventListener('input', () => { + viewer.setTurn(parseInt(turnSlider.value, 10)); + updateUI(); + updateEventLog(); + }); + + speedSlider.addEventListener('input', () => { + const speed = parseInt(speedSlider.value, 10); + viewer.setSpeed(speed); + speedDisplay.textContent = String(speed); + }); + + fogSelect.addEventListener('change', () => { + const value = fogSelect.value; + viewer.setFogOfWar(value === '' ? null : parseInt(value, 10)); + }); + + cellSizeSelect.addEventListener('change', () => { + const size = parseInt(cellSizeSelect.value, 10); + const replay = viewer.getReplay(); + if (replay) { + viewer = new ReplayViewer(canvas, { cellSize: size }); + loadReplay(replay); + } + }); + + viewer.onTurnChange = () => { updateUI(); updateEventLog(); }; + viewer.onPlayStateChange = (playing) => { playBtn.textContent = playing ? 'Pause' : 'Play'; }; + + document.addEventListener('keydown', (e) => { + if (!viewer.getReplay()) return; + switch (e.code) { + case 'Space': + e.preventDefault(); + viewer.togglePlay(); + break; + case 'ArrowLeft': + e.preventDefault(); + viewer.setTurn(viewer.getTurn() - 1); + updateUI(); + updateEventLog(); + break; + case 'ArrowRight': + e.preventDefault(); + viewer.setTurn(viewer.getTurn() + 1); + updateUI(); + updateEventLog(); + break; + case 'Home': + e.preventDefault(); + viewer.setTurn(0); + updateUI(); + updateEventLog(); + break; + case 'End': + e.preventDefault(); + viewer.setTurn(viewer.getTotalTurns() - 1); + updateUI(); + updateEventLog(); + break; + } + }); + + // Load from URL param if provided + if (initialUrl) { + urlInput.value = initialUrl; + loadUrlBtn.click(); + } +} + +// Docs/Getting Started page +function renderDocsPage(): void { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+

Getting Started

+ +
+
+

Overview

+

AI Code Battle is a competitive bot programming platform. You write an HTTP server that controls units on a grid world, competing against other bots for supremacy.

+
+ +
+

Game Basics

+
    +
  • Grid: The game is played on a toroidal (wrapping) grid
  • +
  • Units: Each player controls bots that move one tile per turn
  • +
  • Resources: Collect energy from nodes to spawn new bots
  • +
  • Objectives: Capture enemy cores, eliminate opponents, or dominate through numbers
  • +
+
+ +
+

HTTP Protocol

+

Your bot must expose an HTTPS endpoint that accepts POST requests with JSON game state and returns JSON move commands.

+ +

Request Format

+
{
+  "match_id": "abc123",
+  "turn": 42,
+  "player_id": 0,
+  "config": { ... },
+  "visible_grid": { ... },
+  "my_bots": [
+    { "id": "bot-1", "position": {"row": 10, "col": 20} }
+  ],
+  "my_energy": 5,
+  "my_score": 3
+}
+ +

Response Format

+
{
+  "moves": [
+    { "bot_id": "bot-1", "direction": "N" }
+  ]
+}
+ +

Valid Directions

+

N (North), E (East), S (South), W (West)

+
+ +
+

Authentication

+

Requests from the game engine are signed with HMAC-SHA256. The signature is sent in the X-Signature header.

+

Format: {match_id}.{turn}.{timestamp}.{sha256(body)}

+

Your bot should verify signatures using your API key to ensure requests are authentic.

+
+ +
+

Requirements

+
    +
  • HTTPS endpoint accessible from the internet
  • +
  • Response time under 3 seconds per turn
  • +
  • Handle concurrent requests (multiple matches)
  • +
  • Return valid JSON for every request
  • +
+
+ +
+

Example Bot

+

See the example bots in various languages for reference implementations.

+
+
+
+ + + `; +} + +// 404 page +function renderNotFoundPage(): void { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+

404

+

Page not found

+ Go Home +
+ + + `; +} + +// Start the router +document.addEventListener('DOMContentLoaded', () => { + updateActiveNavLink(); + router.start(); +}); + +// Update nav on initial load +window.addEventListener('load', () => { + updateActiveNavLink(); +}); diff --git a/web/src/pages/bot-profile.ts b/web/src/pages/bot-profile.ts new file mode 100644 index 0000000..f9ceca2 --- /dev/null +++ b/web/src/pages/bot-profile.ts @@ -0,0 +1,181 @@ +// Bot profile page - displays individual bot details + +import { fetchBotProfile, type BotProfile } from '../api-types'; + +export async function renderBotProfilePage(params: Record): Promise { + const app = document.getElementById('app'); + if (!app) return; + + const botId = params.id; + + app.innerHTML = ` +
+ +
Loading...
+
+ `; + + const content = document.getElementById('profile-content'); + const breadcrumbName = document.getElementById('bot-breadcrumb-name'); + if (!content) return; + + try { + const profile = await fetchBotProfile(botId); + if (breadcrumbName) breadcrumbName.textContent = profile.name; + renderProfile(content, profile); + } catch (error) { + content.innerHTML = ` +
+

Failed to load bot profile: ${error}

+

This bot may not exist or data is not yet available.

+ Back to Bot Directory +
+ `; + } +} + +function renderProfile(container: HTMLElement, profile: BotProfile): void { + container.innerHTML = ` +
+

${escapeHtml(profile.name)}

+
+ ${profile.health_status} +
+
+ +
+
+

Rating

+
+ ${profile.rating} + ±${profile.rating_deviation} +
+
+
+ +
+

Statistics

+
+
+ ${profile.matches_played} + Matches +
+
+ ${profile.matches_won} + Wins +
+
+ ${profile.win_rate.toFixed(1)}% + Win Rate +
+
+
+ +
+

Info

+
+
Owner
+
${escapeHtml(profile.owner_id)}
+
Created
+
${formatTimestamp(profile.created_at)}
+
Last Updated
+
${formatTimestamp(profile.updated_at)}
+
+
+ +
+

Recent Matches

+
+ ${renderRecentMatches(profile.recent_matches)} +
+
+
+ `; + + // Render simple rating chart if history exists + renderRatingChart(profile); +} + +function renderRecentMatches(matches: BotProfile['recent_matches']): string { + if (matches.length === 0) { + return '

No matches played yet.

'; + } + + return matches.map(match => { + const opponent = match.participants.find(p => p.bot_id !== match.winner_id); + const won = match.participants.some(p => p.won); + const resultClass = won ? 'match-won' : 'match-lost'; + + return ` +
+ ${won ? 'W' : 'L'} + ${opponent ? escapeHtml(opponent.name) : 'Unknown'} + ${match.participants.map(p => p.score).join(' - ')} + Watch +
+ `; + }).join(''); +} + +function renderRatingChart(profile: BotProfile): void { + const chartContainer = document.getElementById('rating-chart'); + if (!chartContainer || profile.rating_history.length < 2) { + if (chartContainer) { + chartContainer.innerHTML = '

Not enough data for chart.

'; + } + return; + } + + // Simple SVG sparkline + const history = profile.rating_history; + const minRating = Math.min(...history.map(h => h.rating)); + const maxRating = Math.max(...history.map(h => h.rating)); + const range = maxRating - minRating || 1; + const width = 200; + const height = 60; + + const points = history.map((h, i) => { + const x = (i / (history.length - 1)) * width; + const y = height - ((h.rating - minRating) / range) * height; + return `${x},${y}`; + }).join(' '); + + chartContainer.innerHTML = ` + + + +
+ Min: ${Math.round(minRating)} + Max: ${Math.round(maxRating)} +
+ `; +} + +function getStatusClass(status: string): string { + if (status === 'healthy') return 'status-healthy'; + if (status === 'unhealthy') return 'status-unhealthy'; + return 'status-unknown'; +} + +function formatTimestamp(iso: string): string { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/web/src/pages/bots.ts b/web/src/pages/bots.ts new file mode 100644 index 0000000..c60daaf --- /dev/null +++ b/web/src/pages/bots.ts @@ -0,0 +1,88 @@ +// Bot directory page - lists all registered bots + +import { fetchBotDirectory, type BotDirectoryEntry } from '../api-types'; + +export async function renderBotsPage(): Promise { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+

Bot Directory

+
Loading...
+
+ `; + + const content = document.getElementById('bots-content'); + if (!content) return; + + try { + const data = await fetchBotDirectory(); + renderBotsList(content, data.bots, data.updated_at); + } catch (error) { + content.innerHTML = ` +
+

Failed to load bot directory: ${error}

+

Bot data may not be available yet.

+
+ `; + } +} + +function renderBotsList( + container: HTMLElement, + bots: BotDirectoryEntry[], + updatedAt: string +): void { + if (bots.length === 0) { + container.innerHTML = ` +
+

No bots registered yet.

+ Register a Bot +
+ `; + return; + } + + // Sort by rating descending + const sortedBots = [...bots].sort((a, b) => b.rating - a.rating); + + container.innerHTML = ` +

Last updated: ${formatTimestamp(updatedAt)}

+
+ ${sortedBots.map((bot, idx) => renderBotCard(bot, idx + 1)).join('')} +
+ `; +} + +function renderBotCard(bot: BotDirectoryEntry, rank: number): string { + return ` + +
#${rank}
+
+

${escapeHtml(bot.name)}

+
+ ${bot.rating} rating + ${bot.matches_played} matches + ${bot.win_rate.toFixed(1)}% win +
+
+
+ `; +} + +function formatTimestamp(iso: string): string { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/web/src/pages/home.ts b/web/src/pages/home.ts new file mode 100644 index 0000000..bc66d7b --- /dev/null +++ b/web/src/pages/home.ts @@ -0,0 +1,71 @@ +// Home page - landing page with overview + +export function renderHomePage(): void { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+
+

AI Code Battle

+

Program your bot. Compete for supremacy.

+

+ Write an HTTP server that controls units on a grid world. + Collect energy, capture cores, and eliminate your opponents. +

+
+ + +
+
+ +
+

How It Works

+
+
+

Write Code

+

Create a bot in any language that exposes an HTTP endpoint. + Your bot receives game state and returns moves each turn.

+
+
+

Deploy

+

Host your bot anywhere - cloud, container, or bare metal. + Just make sure it's accessible via HTTP.

+
+
+

Compete

+

Your bot plays matches automatically against other registered bots. + Climb the leaderboard with victories.

+
+
+

Watch

+

View replays of every match. Analyze strategies, + learn from defeats, and improve your bot.

+
+
+
+ + +
+ `; +} diff --git a/web/src/pages/leaderboard.ts b/web/src/pages/leaderboard.ts new file mode 100644 index 0000000..5fe6742 --- /dev/null +++ b/web/src/pages/leaderboard.ts @@ -0,0 +1,104 @@ +// Leaderboard page - displays bot rankings + +import { fetchLeaderboard, type LeaderboardEntry } from '../api-types'; + +export async function renderLeaderboardPage(): Promise { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+

Leaderboard

+
Loading...
+
+ `; + + const content = document.getElementById('leaderboard-content'); + if (!content) return; + + try { + const data = await fetchLeaderboard(); + renderLeaderboardTable(content, data.entries, data.updated_at); + } catch (error) { + content.innerHTML = ` +
+

Failed to load leaderboard: ${error}

+

The leaderboard data may not be available yet. Check back after some matches have been played.

+
+ `; + } +} + +function renderLeaderboardTable( + container: HTMLElement, + entries: LeaderboardEntry[], + updatedAt: string +): void { + if (entries.length === 0) { + container.innerHTML = ` +
+

No bots on the leaderboard yet.

+

Bots appear here after completing their first match.

+ Register a Bot +
+ `; + return; + } + + container.innerHTML = ` +

Last updated: ${formatTimestamp(updatedAt)}

+ + + + + + + + + + + + + ${entries.map(entry => renderLeaderboardRow(entry)).join('')} + +
RankBotRatingW/LWin RateStatus
+ `; +} + +function renderLeaderboardRow(entry: LeaderboardEntry): string { + const rankClass = entry.rank <= 3 ? `rank-${entry.rank}` : ''; + const statusClass = entry.health_status === 'healthy' ? 'status-healthy' : + entry.health_status === 'unhealthy' ? 'status-unhealthy' : 'status-unknown'; + + return ` + + ${entry.rank} + + ${escapeHtml(entry.name)} + + + ${entry.rating} + ±${entry.rating_deviation} + + ${entry.matches_won}/${entry.matches_played} + ${entry.win_rate.toFixed(1)}% + ${entry.health_status} + + `; +} + +function formatTimestamp(iso: string): string { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/web/src/pages/matches.ts b/web/src/pages/matches.ts new file mode 100644 index 0000000..f64b324 --- /dev/null +++ b/web/src/pages/matches.ts @@ -0,0 +1,99 @@ +// Match history page - displays recent matches + +import { fetchMatchIndex, type MatchSummary } from '../api-types'; + +export async function renderMatchesPage(): Promise { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+

Match History

+
Loading...
+
+ `; + + const content = document.getElementById('matches-content'); + if (!content) return; + + try { + const data = await fetchMatchIndex(); + renderMatchesList(content, data.matches, data.updated_at); + } catch (error) { + content.innerHTML = ` +
+

Failed to load match history: ${error}

+

Match data may not be available yet.

+
+ `; + } +} + +function renderMatchesList( + container: HTMLElement, + matches: MatchSummary[], + updatedAt: string +): void { + if (matches.length === 0) { + container.innerHTML = ` +
+

No matches have been played yet.

+

Matches will appear here once bots are registered and competing.

+ View Leaderboard +
+ `; + return; + } + + container.innerHTML = ` +

Last updated: ${formatTimestamp(updatedAt)}

+
+ ${matches.map(match => renderMatchCard(match)).join('')} +
+ `; +} + +function renderMatchCard(match: MatchSummary): string { + const completedAt = match.completed_at ? formatTimestamp(match.completed_at) : 'In progress'; + + return ` +
+
+ ${escapeHtml(match.id.slice(0, 8))} + ${completedAt} +
+
+ ${match.participants.map(p => ` +
+ + ${escapeHtml(p.name)} + + ${p.score} + ${p.won ? 'Winner' : ''} +
+ `).join('')} +
+ +
+ `; +} + +function formatTimestamp(iso: string): string { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/web/src/pages/register.ts b/web/src/pages/register.ts new file mode 100644 index 0000000..afe68dd --- /dev/null +++ b/web/src/pages/register.ts @@ -0,0 +1,191 @@ +// Registration page - form to register a new bot + +import { registerBot, type RegisterResponse } from '../api-types'; + +interface FormState { + submitting: boolean; + result: RegisterResponse | null; + error: string | null; +} + +let state: FormState = { + submitting: false, + result: null, + error: null, +}; + +export function renderRegisterPage(): void { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+

Register Your Bot

+ +
+

+ Register your bot to compete on the ladder. Your bot will be matched + against other registered bots automatically. +

+

+ You'll receive an API key after registration. Keep it safe - you'll + need it to manage your bot. +

+
+ +
+ +
+

Requirements

+
    +
  • Your bot must expose an HTTPS endpoint accessible from the internet
  • +
  • The endpoint must respond to POST requests with game state JSON
  • +
  • Response time must be under 3 seconds per turn
  • +
  • See the Getting Started guide for protocol details
  • +
+
+
+ `; + + renderForm(); +} + +function renderForm(): void { + const container = document.getElementById('register-form-container'); + if (!container) return; + + if (state.result?.success) { + container.innerHTML = ` +
+

Registration Successful!

+

Your bot has been registered and will begin competing shortly.

+ +
+

Your API Key

+

Save this key now - it won't be shown again!

+ ${escapeHtml(state.result.api_key || '')} + +
+ +
+

Bot ID: ${escapeHtml(state.result.bot_id || '')}

+
+ + +
+ `; + return; + } + + container.innerHTML = ` +
+ ${state.error ? `
${escapeHtml(state.error)}
` : ''} + +
+ + + 3-32 characters, alphanumeric, dash, or underscore +
+ +
+ + + HTTPS URL where your bot receives move requests +
+ +
+ + + Your identifier for account management +
+ + +
+ `; + + const form = document.getElementById('register-form') as HTMLFormElement; + if (form) { + form.addEventListener('submit', handleSubmit); + } +} + +async function handleSubmit(e: Event): Promise { + e.preventDefault(); + + const form = e.target as HTMLFormElement; + const formData = new FormData(form); + + const name = formData.get('name') as string; + const endpointUrl = formData.get('endpoint_url') as string; + const ownerId = formData.get('owner_id') as string; + + state = { + ...state, + submitting: true, + error: null, + }; + renderForm(); + + try { + const result = await registerBot({ + name, + endpoint_url: endpointUrl, + owner_id: ownerId, + }); + + state = { + ...state, + submitting: false, + result, + error: result.success ? null : result.error || 'Registration failed', + }; + renderForm(); + } catch (err) { + state = { + ...state, + submitting: false, + error: `Network error: ${err}`, + }; + renderForm(); + } +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/web/src/router.ts b/web/src/router.ts new file mode 100644 index 0000000..12ced21 --- /dev/null +++ b/web/src/router.ts @@ -0,0 +1,89 @@ +// Simple hash-based router for single-page navigation + +export type RouteHandler = (params: Record) => void | Promise; + +interface Route { + pattern: RegExp; + handler: RouteHandler; + paramNames: string[]; +} + +class Router { + private routes: Route[] = []; + private notFoundHandler: RouteHandler | null = null; + + /** + * Register a route with a pattern like "/leaderboard" or "/bot/:id" + */ + on(pattern: string, handler: RouteHandler): this { + const paramNames: string[] = []; + const regexPattern = pattern.replace(/:(\w+)/g, (_, name) => { + paramNames.push(name); + return '([^/]+)'; + }); + + this.routes.push({ + pattern: new RegExp(`^${regexPattern}$`), + handler, + paramNames, + }); + + return this; + } + + /** + * Register a handler for unmatched routes + */ + notFound(handler: RouteHandler): this { + this.notFoundHandler = handler; + return this; + } + + /** + * Navigate to a path + */ + navigate(path: string): void { + window.location.hash = path; + } + + /** + * Get current path from hash + */ + getCurrentPath(): string { + const hash = window.location.hash.slice(1); // Remove # + return hash || '/'; + } + + /** + * Start listening for hash changes + */ + start(): void { + window.addEventListener('hashchange', () => this.handleRoute()); + this.handleRoute(); + } + + /** + * Handle the current route + */ + private handleRoute(): void { + const path = this.getCurrentPath(); + + for (const route of this.routes) { + const match = path.match(route.pattern); + if (match) { + const params: Record = {}; + route.paramNames.forEach((name, idx) => { + params[name] = decodeURIComponent(match[idx + 1]); + }); + route.handler(params); + return; + } + } + + if (this.notFoundHandler) { + this.notFoundHandler({}); + } + } +} + +export const router = new Router(); diff --git a/web/vite.config.ts b/web/vite.config.ts index 7e527a4..1cc7e5c 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,10 +1,17 @@ import { defineConfig } from 'vite' +import { resolve } from 'path' export default defineConfig({ root: '.', build: { outDir: 'dist', sourcemap: true, + rollupOptions: { + input: { + main: resolve(__dirname, 'index.html'), + app: resolve(__dirname, 'app.html'), + }, + }, }, server: { port: 3000,