Start Phase 5: Add SPA web platform with routing and pages

- Create app.html as SPA shell with navigation header and dark theme
- Add hash-based router (router.ts) for client-side navigation
- Implement page components:
  - Home page with hero section and feature overview
  - Leaderboard with ranking table and status indicators
  - Match history with match cards and participant info
  - Bot directory with bot cards sorted by rating
  - Bot profile with stats, rating sparkline chart, and recent matches
  - Registration form with API key display
  - Replay viewer (integrated from Phase 3)
  - Docs/Getting Started page with protocol overview
- Add API client (api-types.ts) for fetching data from Worker API
- Update vite.config.ts for multi-page build (index.html + app.html)
- Update PROGRESS.md with Phase 5 status and exit criteria

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-24 09:14:15 -04:00
parent 4bbc3f0515
commit 6f1cbbcad2
12 changed files with 2409 additions and 138 deletions

View file

@ -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)
```

689
web/app.html Normal file
View file

@ -0,0 +1,689 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Code Battle</title>
<style>
/* Reset and base styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--text-primary: #f8fafc;
--text-secondary: #e2e8f0;
--text-muted: #94a3b8;
--accent: #3b82f6;
--accent-hover: #2563eb;
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
--border: #475569;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: var(--bg-primary);
color: var(--text-secondary);
min-height: 100vh;
line-height: 1.5;
}
/* Navigation */
nav {
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 0 20px;
position: sticky;
top: 0;
z-index: 100;
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
}
.nav-brand {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: var(--text-primary);
font-weight: 700;
font-size: 1.25rem;
}
.nav-brand:hover {
color: var(--accent);
}
.nav-links {
display: flex;
gap: 8px;
}
.nav-link {
color: var(--text-muted);
text-decoration: none;
padding: 8px 16px;
border-radius: 6px;
transition: all 0.2s;
}
.nav-link:hover {
color: var(--text-primary);
background-color: var(--bg-tertiary);
}
.nav-link.active {
color: var(--text-primary);
background-color: var(--accent);
}
/* Main content area */
#app {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
min-height: calc(100vh - 60px);
}
/* Common page styles */
.page-title {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 20px;
}
.loading {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
.error {
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid var(--error);
border-radius: 8px;
padding: 20px;
color: var(--error);
}
.error .hint {
color: var(--text-muted);
font-size: 0.875rem;
margin-top: 10px;
}
.empty-state {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
.updated-at {
color: var(--text-muted);
font-size: 0.875rem;
margin-bottom: 16px;
}
/* Buttons */
.btn {
display: inline-block;
background-color: var(--bg-tertiary);
color: var(--text-primary);
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
text-decoration: none;
transition: background-color 0.2s;
}
.btn:hover {
background-color: var(--border);
}
.btn.primary {
background-color: var(--accent);
}
.btn.primary:hover {
background-color: var(--accent-hover);
}
.btn.secondary {
background-color: var(--bg-tertiary);
}
.btn.small {
padding: 6px 12px;
font-size: 12px;
}
/* Home page */
.home-page .hero {
text-align: center;
padding: 60px 20px;
background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-primary) 100%);
border-radius: 12px;
margin-bottom: 40px;
}
.home-page .hero h1 {
font-size: 3rem;
color: var(--text-primary);
margin-bottom: 10px;
}
.home-page .tagline {
font-size: 1.5rem;
color: var(--accent);
margin-bottom: 20px;
}
.home-page .description {
font-size: 1.125rem;
color: var(--text-muted);
max-width: 600px;
margin: 0 auto 30px;
}
.cta-buttons {
display: flex;
gap: 12px;
justify-content: center;
}
.features, .quick-links {
margin-bottom: 40px;
}
.features h2, .quick-links h2 {
font-size: 1.5rem;
color: var(--text-primary);
margin-bottom: 20px;
}
.feature-grid, .link-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
}
.feature, .link-card {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 20px;
}
.feature h3, .link-card h3 {
color: var(--text-primary);
margin-bottom: 10px;
}
.feature p, .link-card p {
color: var(--text-muted);
font-size: 0.875rem;
}
.link-card {
text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
}
.link-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Leaderboard */
.leaderboard-table {
width: 100%;
border-collapse: collapse;
background-color: var(--bg-secondary);
border-radius: 8px;
overflow: hidden;
}
.leaderboard-table th,
.leaderboard-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid var(--bg-tertiary);
}
.leaderboard-table th {
background-color: var(--bg-tertiary);
color: var(--text-muted);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.leaderboard-table tr:hover {
background-color: var(--bg-tertiary);
}
.leaderboard-table .rank {
font-weight: 700;
color: var(--text-muted);
}
.leaderboard-table tr.rank-1 .rank { color: #fbbf24; }
.leaderboard-table tr.rank-2 .rank { color: #94a3b8; }
.leaderboard-table tr.rank-3 .rank { color: #cd7f32; }
.leaderboard-table .bot-name a {
color: var(--text-primary);
text-decoration: none;
}
.leaderboard-table .bot-name a:hover {
color: var(--accent);
}
.rating-value {
font-weight: 600;
color: var(--text-primary);
}
.rating-dev {
color: var(--text-muted);
font-size: 0.875rem;
margin-left: 4px;
}
.status-healthy { color: var(--success); }
.status-unhealthy { color: var(--error); }
.status-unknown { color: var(--text-muted); }
/* Bot cards grid */
.bots-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.bot-card {
display: flex;
align-items: center;
gap: 16px;
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 16px;
text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
}
.bot-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.bot-rank {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-muted);
min-width: 50px;
}
.bot-info .bot-name {
color: var(--text-primary);
font-weight: 600;
margin-bottom: 4px;
}
.bot-stats {
display: flex;
gap: 12px;
color: var(--text-muted);
font-size: 0.75rem;
}
/* Match cards */
.matches-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.match-card {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 16px;
}
.match-header {
display: flex;
justify-content: space-between;
color: var(--text-muted);
font-size: 0.875rem;
margin-bottom: 12px;
}
.match-id {
font-family: monospace;
}
.match-participants {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.participant {
display: flex;
align-items: center;
gap: 12px;
}
.participant-name {
color: var(--text-primary);
text-decoration: none;
}
.participant-name:hover {
color: var(--accent);
}
.participant-score {
color: var(--text-muted);
}
.winner-badge {
background-color: var(--success);
color: white;
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 4px;
}
.match-footer {
display: flex;
align-items: center;
gap: 16px;
color: var(--text-muted);
font-size: 0.875rem;
}
.match-footer .btn {
margin-left: auto;
}
/* Bot profile */
.breadcrumb {
color: var(--text-muted);
font-size: 0.875rem;
margin-bottom: 20px;
}
.breadcrumb a {
color: var(--accent);
text-decoration: none;
}
.profile-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 30px;
}
.profile-header h1 {
font-size: 2rem;
color: var(--text-primary);
}
.profile-status {
padding: 4px 12px;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
}
.profile-status.status-healthy {
background-color: rgba(34, 197, 94, 0.2);
color: var(--success);
}
.profile-status.status-unhealthy {
background-color: rgba(239, 68, 68, 0.2);
color: var(--error);
}
.profile-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.profile-section {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 20px;
}
.profile-section h2 {
font-size: 1rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 16px;
}
.rating-display {
margin-bottom: 16px;
}
.rating-main {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
text-align: center;
}
.stat-value {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
}
.stat-label {
font-size: 0.75rem;
color: var(--text-muted);
}
.meta-list dt {
color: var(--text-muted);
font-size: 0.75rem;
margin-top: 12px;
}
.meta-list dd {
color: var(--text-primary);
}
.rating-sparkline {
width: 100%;
height: 60px;
}
.rating-range {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 8px;
}
/* Registration form */
.register-intro {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 20px;
margin-bottom: 30px;
}
.register-intro p {
color: var(--text-muted);
margin-bottom: 10px;
}
.register-form {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 30px;
max-width: 500px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
color: var(--text-primary);
font-weight: 500;
margin-bottom: 8px;
}
.form-group input {
width: 100%;
background-color: var(--bg-primary);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 12px;
border-radius: 6px;
font-size: 14px;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.form-group .hint {
display: block;
color: var(--text-muted);
font-size: 0.75rem;
margin-top: 6px;
}
.error-message {
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid var(--error);
border-radius: 6px;
padding: 12px;
color: var(--error);
margin-bottom: 20px;
}
.register-success {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 30px;
max-width: 500px;
}
.register-success h2 {
color: var(--success);
margin-bottom: 10px;
}
.api-key-display {
background-color: var(--bg-primary);
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.api-key-display .warning {
color: var(--warning);
margin-bottom: 10px;
}
.api-key {
display: block;
background-color: var(--bg-tertiary);
padding: 12px;
border-radius: 6px;
font-family: monospace;
word-break: break-all;
margin-bottom: 12px;
}
.next-steps {
display: flex;
gap: 12px;
margin-top: 20px;
}
.register-help {
margin-top: 30px;
}
.register-help h2 {
color: var(--text-primary);
margin-bottom: 12px;
}
.register-help ul {
list-style: none;
color: var(--text-muted);
}
.register-help li {
padding: 8px 0;
padding-left: 20px;
position: relative;
}
.register-help li::before {
content: '•';
position: absolute;
left: 0;
color: var(--accent);
}
</style>
</head>
<body>
<nav>
<div class="nav-container">
<a href="#/" class="nav-brand">AI Code Battle</a>
<div class="nav-links">
<a href="#/leaderboard" class="nav-link">Leaderboard</a>
<a href="#/matches" class="nav-link">Matches</a>
<a href="#/bots" class="nav-link">Bots</a>
<a href="#/register" class="nav-link">Register</a>
<a href="#/replay" class="nav-link">Replay Viewer</a>
</div>
</div>
</nav>
<div id="app"></div>
<script type="module" src="/src/app.ts"></script>
</body>
</html>

143
web/src/api-types.ts Normal file
View file

@ -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<LeaderboardIndex> {
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> {
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> {
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> {
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();
}
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();
}

691
web/src/app.ts Normal file
View file

@ -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<string, string>): void {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="replay-page">
<h1 class="page-title">Replay Viewer</h1>
<div class="replay-layout">
<div class="replay-main">
<div class="canvas-wrapper">
<canvas id="replay-canvas"></canvas>
<div id="no-replay" class="no-replay-message">Load a replay file to view</div>
</div>
</div>
<div class="replay-sidebar">
<div class="panel">
<h2>Load Replay</h2>
<div class="load-controls">
<div class="file-input-wrapper">
<label class="btn secondary" for="file-input">Choose File</label>
<input type="file" id="file-input" accept=".json" style="display: none;">
</div>
<div class="url-input-group">
<input type="text" id="url-input" placeholder="Or enter URL...">
<button id="load-url-btn" class="btn primary">Load</button>
</div>
</div>
</div>
<div class="panel">
<h2>Playback</h2>
<div class="playback-controls">
<button id="play-btn" class="btn" disabled>Play</button>
<button id="prev-btn" class="btn" disabled>Prev</button>
<button id="next-btn" class="btn" disabled>Next</button>
<button id="reset-btn" class="btn" disabled>Reset</button>
</div>
<div class="slider-group">
<label>Turn: <span id="turn-display">0</span> / <span id="total-turns">0</span></label>
<input type="range" id="turn-slider" min="0" max="0" value="0" disabled>
</div>
<div class="slider-group">
<label>Speed: <span id="speed-display">100</span>ms/turn</label>
<input type="range" id="speed-slider" min="20" max="1000" value="100">
</div>
</div>
<div class="panel">
<h2>View Options</h2>
<div class="view-options">
<label for="fog-select">Fog of War:</label>
<select id="fog-select">
<option value="">Disabled (full view)</option>
</select>
<label for="cell-size-select" style="margin-top: 10px;">Cell Size:</label>
<select id="cell-size-select">
<option value="6">Small (6px)</option>
<option value="8">Medium (8px)</option>
<option value="10" selected>Large (10px)</option>
<option value="12">X-Large (12px)</option>
</select>
</div>
</div>
<div class="panel">
<h2>Match Info</h2>
<dl class="match-info">
<dt>Match ID</dt>
<dd id="info-match-id">-</dd>
<dt>Winner</dt>
<dd id="info-winner">-</dd>
<dt>Turns</dt>
<dd id="info-turns">-</dd>
<dt>Reason</dt>
<dd id="info-reason">-</dd>
</dl>
</div>
<div class="panel">
<h2>Events This Turn</h2>
<div class="event-log" id="event-log">
<div class="no-events">No events</div>
</div>
</div>
<div class="keyboard-shortcuts">
<kbd>Space</kbd> Play/Pause
<kbd></kbd><kbd></kbd> Step
<kbd>Home</kbd><kbd>End</kbd> First/Last
</div>
</div>
</div>
</div>
<style>
.replay-page .page-title {
margin-bottom: 20px;
}
.replay-layout {
display: flex;
gap: 20px;
}
.replay-main {
flex: 1;
min-width: 0;
}
.canvas-wrapper {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 10px;
overflow: auto;
max-height: 80vh;
}
#replay-canvas {
display: block;
}
.no-replay-message {
color: var(--text-muted);
text-align: center;
padding: 60px 20px;
}
.replay-sidebar {
width: 300px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 15px;
}
.panel {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 15px;
}
.panel h2 {
font-size: 1rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 12px;
}
.load-controls {
display: flex;
flex-direction: column;
gap: 10px;
}
.url-input-group {
display: flex;
gap: 8px;
}
.url-input-group input {
flex: 1;
background-color: var(--bg-primary);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 8px;
border-radius: 6px;
font-size: 14px;
}
.playback-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.playback-controls .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.slider-group {
margin-bottom: 10px;
}
.slider-group label {
display: block;
color: var(--text-muted);
font-size: 0.875rem;
margin-bottom: 6px;
}
.slider-group input[type="range"] {
width: 100%;
}
.view-options {
display: flex;
flex-direction: column;
}
.view-options label {
color: var(--text-muted);
font-size: 0.875rem;
margin-bottom: 6px;
}
.view-options select {
background-color: var(--bg-primary);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 8px;
border-radius: 6px;
font-size: 14px;
}
.match-info dt {
color: var(--text-muted);
font-size: 0.75rem;
text-transform: uppercase;
margin-top: 10px;
}
.match-info dd {
color: var(--text-primary);
}
.event-log {
max-height: 150px;
overflow-y: auto;
font-size: 0.75rem;
font-family: monospace;
}
.event-log .event {
padding: 4px 0;
border-bottom: 1px solid var(--bg-tertiary);
}
.event-log .event:last-child {
border-bottom: none;
}
.no-events {
color: var(--text-muted);
}
.keyboard-shortcuts {
font-size: 0.75rem;
color: var(--text-muted);
}
.keyboard-shortcuts kbd {
background-color: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
margin-right: 4px;
}
@media (max-width: 900px) {
.replay-layout {
flex-direction: column;
}
.replay-sidebar {
width: 100%;
}
}
</style>
`;
// 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 = '<div class="no-events">No events</div>';
return;
}
eventLogDiv.innerHTML = events.map(e => {
const type = e.type.replace(/_/g, ' ');
return `<div class="event"><span style="color: #fbbf24;">${type}</span></div>`;
}).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 = '<option value="">Disabled (full view)</option>';
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 = `
<div class="docs-page">
<h1 class="page-title">Getting Started</h1>
<div class="docs-content">
<section>
<h2>Overview</h2>
<p>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.</p>
</section>
<section>
<h2>Game Basics</h2>
<ul>
<li><strong>Grid:</strong> The game is played on a toroidal (wrapping) grid</li>
<li><strong>Units:</strong> Each player controls bots that move one tile per turn</li>
<li><strong>Resources:</strong> Collect energy from nodes to spawn new bots</li>
<li><strong>Objectives:</strong> Capture enemy cores, eliminate opponents, or dominate through numbers</li>
</ul>
</section>
<section>
<h2>HTTP Protocol</h2>
<p>Your bot must expose an HTTPS endpoint that accepts POST requests with JSON game state and returns JSON move commands.</p>
<h3>Request Format</h3>
<pre><code>{
"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
}</code></pre>
<h3>Response Format</h3>
<pre><code>{
"moves": [
{ "bot_id": "bot-1", "direction": "N" }
]
}</code></pre>
<h3>Valid Directions</h3>
<p><code>N</code> (North), <code>E</code> (East), <code>S</code> (South), <code>W</code> (West)</p>
</section>
<section>
<h2>Authentication</h2>
<p>Requests from the game engine are signed with HMAC-SHA256. The signature is sent in the <code>X-Signature</code> header.</p>
<p>Format: <code>{match_id}.{turn}.{timestamp}.{sha256(body)}</code></p>
<p>Your bot should verify signatures using your API key to ensure requests are authentic.</p>
</section>
<section>
<h2>Requirements</h2>
<ul>
<li>HTTPS endpoint accessible from the internet</li>
<li>Response time under 3 seconds per turn</li>
<li>Handle concurrent requests (multiple matches)</li>
<li>Return valid JSON for every request</li>
</ul>
</section>
<section>
<h2>Example Bot</h2>
<p>See the <a href="https://github.com/aicodebattle/acb/tree/main/bots" target="_blank">example bots</a> in various languages for reference implementations.</p>
</section>
</div>
</div>
<style>
.docs-content {
max-width: 800px;
}
.docs-content section {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.docs-content h2 {
color: var(--text-primary);
margin-bottom: 12px;
}
.docs-content h3 {
color: var(--text-primary);
margin: 16px 0 8px;
font-size: 1rem;
}
.docs-content p {
color: var(--text-muted);
margin-bottom: 10px;
}
.docs-content ul {
color: var(--text-muted);
margin-left: 20px;
}
.docs-content li {
margin-bottom: 6px;
}
.docs-content pre {
background-color: var(--bg-primary);
border-radius: 6px;
padding: 16px;
overflow-x: auto;
margin: 10px 0;
}
.docs-content code {
font-family: 'Fira Code', 'Monaco', monospace;
font-size: 0.875rem;
color: var(--text-secondary);
}
.docs-content a {
color: var(--accent);
}
</style>
`;
}
// 404 page
function renderNotFoundPage(): void {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="not-found-page">
<h1>404</h1>
<p>Page not found</p>
<a href="#/" class="btn primary">Go Home</a>
</div>
<style>
.not-found-page {
text-align: center;
padding: 100px 20px;
}
.not-found-page h1 {
font-size: 4rem;
color: var(--text-primary);
margin-bottom: 10px;
}
.not-found-page p {
color: var(--text-muted);
margin-bottom: 20px;
}
</style>
`;
}
// Start the router
document.addEventListener('DOMContentLoaded', () => {
updateActiveNavLink();
router.start();
});
// Update nav on initial load
window.addEventListener('load', () => {
updateActiveNavLink();
});

View file

@ -0,0 +1,181 @@
// Bot profile page - displays individual bot details
import { fetchBotProfile, type BotProfile } from '../api-types';
export async function renderBotProfilePage(params: Record<string, string>): Promise<void> {
const app = document.getElementById('app');
if (!app) return;
const botId = params.id;
app.innerHTML = `
<div class="bot-profile-page">
<nav class="breadcrumb">
<a href="#/bots">Bots</a> / <span id="bot-breadcrumb-name">Loading...</span>
</nav>
<div id="profile-content" class="loading">Loading...</div>
</div>
`;
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 = `
<div class="error">
<p>Failed to load bot profile: ${error}</p>
<p class="hint">This bot may not exist or data is not yet available.</p>
<a href="#/bots" class="btn secondary">Back to Bot Directory</a>
</div>
`;
}
}
function renderProfile(container: HTMLElement, profile: BotProfile): void {
container.innerHTML = `
<div class="profile-header">
<h1>${escapeHtml(profile.name)}</h1>
<div class="profile-status ${getStatusClass(profile.health_status)}">
${profile.health_status}
</div>
</div>
<div class="profile-grid">
<div class="profile-section ratings">
<h2>Rating</h2>
<div class="rating-display">
<span class="rating-main">${profile.rating}</span>
<span class="rating-dev">±${profile.rating_deviation}</span>
</div>
<div class="rating-chart" id="rating-chart"></div>
</div>
<div class="profile-section stats">
<h2>Statistics</h2>
<div class="stats-grid">
<div class="stat">
<span class="stat-value">${profile.matches_played}</span>
<span class="stat-label">Matches</span>
</div>
<div class="stat">
<span class="stat-value">${profile.matches_won}</span>
<span class="stat-label">Wins</span>
</div>
<div class="stat">
<span class="stat-value">${profile.win_rate.toFixed(1)}%</span>
<span class="stat-label">Win Rate</span>
</div>
</div>
</div>
<div class="profile-section meta">
<h2>Info</h2>
<dl class="meta-list">
<dt>Owner</dt>
<dd>${escapeHtml(profile.owner_id)}</dd>
<dt>Created</dt>
<dd>${formatTimestamp(profile.created_at)}</dd>
<dt>Last Updated</dt>
<dd>${formatTimestamp(profile.updated_at)}</dd>
</dl>
</div>
<div class="profile-section history">
<h2>Recent Matches</h2>
<div class="matches-list" id="recent-matches">
${renderRecentMatches(profile.recent_matches)}
</div>
</div>
</div>
`;
// Render simple rating chart if history exists
renderRatingChart(profile);
}
function renderRecentMatches(matches: BotProfile['recent_matches']): string {
if (matches.length === 0) {
return '<p class="empty-state">No matches played yet.</p>';
}
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 `
<div class="match-item ${resultClass}">
<span class="match-result">${won ? 'W' : 'L'}</span>
<span class="match-opponent">${opponent ? escapeHtml(opponent.name) : 'Unknown'}</span>
<span class="match-score">${match.participants.map(p => p.score).join(' - ')}</span>
<a href="#/replay?url=/replays/${match.id}.json" class="btn small">Watch</a>
</div>
`;
}).join('');
}
function renderRatingChart(profile: BotProfile): void {
const chartContainer = document.getElementById('rating-chart');
if (!chartContainer || profile.rating_history.length < 2) {
if (chartContainer) {
chartContainer.innerHTML = '<p class="empty-state">Not enough data for chart.</p>';
}
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 = `
<svg class="rating-sparkline" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
<polyline
points="${points}"
fill="none"
stroke="#3b82f6"
stroke-width="2"
/>
</svg>
<div class="rating-range">
<span>Min: ${Math.round(minRating)}</span>
<span>Max: ${Math.round(maxRating)}</span>
</div>
`;
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

88
web/src/pages/bots.ts Normal file
View file

@ -0,0 +1,88 @@
// Bot directory page - lists all registered bots
import { fetchBotDirectory, type BotDirectoryEntry } from '../api-types';
export async function renderBotsPage(): Promise<void> {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="bots-page">
<h1>Bot Directory</h1>
<div id="bots-content" class="loading">Loading...</div>
</div>
`;
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 = `
<div class="error">
<p>Failed to load bot directory: ${error}</p>
<p class="hint">Bot data may not be available yet.</p>
</div>
`;
}
}
function renderBotsList(
container: HTMLElement,
bots: BotDirectoryEntry[],
updatedAt: string
): void {
if (bots.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>No bots registered yet.</p>
<a href="#/register" class="btn primary">Register a Bot</a>
</div>
`;
return;
}
// Sort by rating descending
const sortedBots = [...bots].sort((a, b) => b.rating - a.rating);
container.innerHTML = `
<p class="updated-at">Last updated: ${formatTimestamp(updatedAt)}</p>
<div class="bots-grid">
${sortedBots.map((bot, idx) => renderBotCard(bot, idx + 1)).join('')}
</div>
`;
}
function renderBotCard(bot: BotDirectoryEntry, rank: number): string {
return `
<a href="#/bot/${encodeURIComponent(bot.id)}" class="bot-card">
<div class="bot-rank">#${rank}</div>
<div class="bot-info">
<h3 class="bot-name">${escapeHtml(bot.name)}</h3>
<div class="bot-stats">
<span class="bot-rating">${bot.rating} rating</span>
<span class="bot-matches">${bot.matches_played} matches</span>
<span class="bot-winrate">${bot.win_rate.toFixed(1)}% win</span>
</div>
</div>
</a>
`;
}
function formatTimestamp(iso: string): string {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

71
web/src/pages/home.ts Normal file
View file

@ -0,0 +1,71 @@
// Home page - landing page with overview
export function renderHomePage(): void {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="home-page">
<section class="hero">
<h1>AI Code Battle</h1>
<p class="tagline">Program your bot. Compete for supremacy.</p>
<p class="description">
Write an HTTP server that controls units on a grid world.
Collect energy, capture cores, and eliminate your opponents.
</p>
<div class="cta-buttons">
<button class="btn primary" onclick="window.location.hash='/register'">Register Your Bot</button>
<button class="btn secondary" onclick="window.location.hash='/docs'">Get Started</button>
</div>
</section>
<section class="features">
<h2>How It Works</h2>
<div class="feature-grid">
<div class="feature">
<h3>Write Code</h3>
<p>Create a bot in any language that exposes an HTTP endpoint.
Your bot receives game state and returns moves each turn.</p>
</div>
<div class="feature">
<h3>Deploy</h3>
<p>Host your bot anywhere - cloud, container, or bare metal.
Just make sure it's accessible via HTTP.</p>
</div>
<div class="feature">
<h3>Compete</h3>
<p>Your bot plays matches automatically against other registered bots.
Climb the leaderboard with victories.</p>
</div>
<div class="feature">
<h3>Watch</h3>
<p>View replays of every match. Analyze strategies,
learn from defeats, and improve your bot.</p>
</div>
</div>
</section>
<section class="quick-links">
<h2>Explore</h2>
<div class="link-grid">
<a href="#/leaderboard" class="link-card">
<h3>Leaderboard</h3>
<p>See how bots rank on the competitive ladder</p>
</a>
<a href="#/matches" class="link-card">
<h3>Match History</h3>
<p>Browse recent matches and watch replays</p>
</a>
<a href="#/bots" class="link-card">
<h3>Bot Directory</h3>
<p>View all registered bots and their profiles</p>
</a>
<a href="#/replay" class="link-card">
<h3>Replay Viewer</h3>
<p>Load and watch match replays</p>
</a>
</div>
</section>
</div>
`;
}

View file

@ -0,0 +1,104 @@
// Leaderboard page - displays bot rankings
import { fetchLeaderboard, type LeaderboardEntry } from '../api-types';
export async function renderLeaderboardPage(): Promise<void> {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="leaderboard-page">
<h1>Leaderboard</h1>
<div id="leaderboard-content" class="loading">Loading...</div>
</div>
`;
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 = `
<div class="error">
<p>Failed to load leaderboard: ${error}</p>
<p class="hint">The leaderboard data may not be available yet. Check back after some matches have been played.</p>
</div>
`;
}
}
function renderLeaderboardTable(
container: HTMLElement,
entries: LeaderboardEntry[],
updatedAt: string
): void {
if (entries.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>No bots on the leaderboard yet.</p>
<p>Bots appear here after completing their first match.</p>
<a href="#/register" class="btn primary">Register a Bot</a>
</div>
`;
return;
}
container.innerHTML = `
<p class="updated-at">Last updated: ${formatTimestamp(updatedAt)}</p>
<table class="leaderboard-table">
<thead>
<tr>
<th>Rank</th>
<th>Bot</th>
<th>Rating</th>
<th>W/L</th>
<th>Win Rate</th>
<th>Status</th>
</tr>
</thead>
<tbody>
${entries.map(entry => renderLeaderboardRow(entry)).join('')}
</tbody>
</table>
`;
}
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 `
<tr class="${rankClass}">
<td class="rank">${entry.rank}</td>
<td class="bot-name">
<a href="#/bot/${encodeURIComponent(entry.bot_id)}">${escapeHtml(entry.name)}</a>
</td>
<td class="rating">
<span class="rating-value">${entry.rating}</span>
<span class="rating-dev">±${entry.rating_deviation}</span>
</td>
<td class="wl">${entry.matches_won}/${entry.matches_played}</td>
<td class="win-rate">${entry.win_rate.toFixed(1)}%</td>
<td class="status ${statusClass}">${entry.health_status}</td>
</tr>
`;
}
function formatTimestamp(iso: string): string {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

99
web/src/pages/matches.ts Normal file
View file

@ -0,0 +1,99 @@
// Match history page - displays recent matches
import { fetchMatchIndex, type MatchSummary } from '../api-types';
export async function renderMatchesPage(): Promise<void> {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="matches-page">
<h1>Match History</h1>
<div id="matches-content" class="loading">Loading...</div>
</div>
`;
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 = `
<div class="error">
<p>Failed to load match history: ${error}</p>
<p class="hint">Match data may not be available yet.</p>
</div>
`;
}
}
function renderMatchesList(
container: HTMLElement,
matches: MatchSummary[],
updatedAt: string
): void {
if (matches.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>No matches have been played yet.</p>
<p>Matches will appear here once bots are registered and competing.</p>
<a href="#/leaderboard" class="btn primary">View Leaderboard</a>
</div>
`;
return;
}
container.innerHTML = `
<p class="updated-at">Last updated: ${formatTimestamp(updatedAt)}</p>
<div class="matches-list">
${matches.map(match => renderMatchCard(match)).join('')}
</div>
`;
}
function renderMatchCard(match: MatchSummary): string {
const completedAt = match.completed_at ? formatTimestamp(match.completed_at) : 'In progress';
return `
<div class="match-card" data-match-id="${escapeHtml(match.id)}">
<div class="match-header">
<span class="match-id">${escapeHtml(match.id.slice(0, 8))}</span>
<span class="match-time">${completedAt}</span>
</div>
<div class="match-participants">
${match.participants.map(p => `
<div class="participant ${p.won ? 'winner' : ''}">
<a href="#/bot/${encodeURIComponent(p.bot_id)}" class="participant-name">
${escapeHtml(p.name)}
</a>
<span class="participant-score">${p.score}</span>
${p.won ? '<span class="winner-badge">Winner</span>' : ''}
</div>
`).join('')}
</div>
<div class="match-footer">
<span class="match-turns">${match.turns ?? '-'} turns</span>
<span class="match-reason">${match.end_reason ?? '-'}</span>
<a href="#/replay?url=/replays/${match.id}.json" class="btn small">Watch</a>
</div>
</div>
`;
}
function formatTimestamp(iso: string): string {
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

191
web/src/pages/register.ts Normal file
View file

@ -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 = `
<div class="register-page">
<h1>Register Your Bot</h1>
<div class="register-intro">
<p>
Register your bot to compete on the ladder. Your bot will be matched
against other registered bots automatically.
</p>
<p>
You'll receive an API key after registration. Keep it safe - you'll
need it to manage your bot.
</p>
</div>
<div id="register-form-container"></div>
<div class="register-help">
<h2>Requirements</h2>
<ul>
<li>Your bot must expose an HTTPS endpoint accessible from the internet</li>
<li>The endpoint must respond to POST requests with game state JSON</li>
<li>Response time must be under 3 seconds per turn</li>
<li>See the <a href="#/docs">Getting Started guide</a> for protocol details</li>
</ul>
</div>
</div>
`;
renderForm();
}
function renderForm(): void {
const container = document.getElementById('register-form-container');
if (!container) return;
if (state.result?.success) {
container.innerHTML = `
<div class="register-success">
<h2>Registration Successful!</h2>
<p>Your bot has been registered and will begin competing shortly.</p>
<div class="api-key-display">
<h3>Your API Key</h3>
<p class="warning">Save this key now - it won't be shown again!</p>
<code class="api-key">${escapeHtml(state.result.api_key || '')}</code>
<button class="btn secondary" onclick="navigator.clipboard.writeText('${escapeHtml(state.result.api_key || '')}').then(() => alert('Copied!'))">
Copy to Clipboard
</button>
</div>
<div class="bot-info">
<p><strong>Bot ID:</strong> ${escapeHtml(state.result.bot_id || '')}</p>
</div>
<div class="next-steps">
<a href="#/bot/${encodeURIComponent(state.result.bot_id || '')}" class="btn primary">View Bot Profile</a>
<a href="#/leaderboard" class="btn secondary">View Leaderboard</a>
</div>
</div>
`;
return;
}
container.innerHTML = `
<form id="register-form" class="register-form">
${state.error ? `<div class="error-message">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group">
<label for="bot-name">Bot Name</label>
<input
type="text"
id="bot-name"
name="name"
placeholder="MyAwesomeBot"
required
pattern="[a-zA-Z0-9_-]+"
minlength="3"
maxlength="32"
${state.submitting ? 'disabled' : ''}
>
<span class="hint">3-32 characters, alphanumeric, dash, or underscore</span>
</div>
<div class="form-group">
<label for="endpoint-url">Endpoint URL</label>
<input
type="url"
id="endpoint-url"
name="endpoint_url"
placeholder="https://my-bot.example.com/move"
required
${state.submitting ? 'disabled' : ''}
>
<span class="hint">HTTPS URL where your bot receives move requests</span>
</div>
<div class="form-group">
<label for="owner-id">Owner ID</label>
<input
type="text"
id="owner-id"
name="owner_id"
placeholder="your-email@example.com"
required
maxlength="64"
${state.submitting ? 'disabled' : ''}
>
<span class="hint">Your identifier for account management</span>
</div>
<button type="submit" class="btn primary" ${state.submitting ? 'disabled' : ''}>
${state.submitting ? 'Registering...' : 'Register Bot'}
</button>
</form>
`;
const form = document.getElementById('register-form') as HTMLFormElement;
if (form) {
form.addEventListener('submit', handleSubmit);
}
}
async function handleSubmit(e: Event): Promise<void> {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

89
web/src/router.ts Normal file
View file

@ -0,0 +1,89 @@
// Simple hash-based router for single-page navigation
export type RouteHandler = (params: Record<string, string>) => void | Promise<void>;
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<string, string> = {};
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();

View file

@ -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,