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:
parent
4bbc3f0515
commit
6f1cbbcad2
12 changed files with 2409 additions and 138 deletions
194
PROGRESS.md
194
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)
|
||||
```
|
||||
|
|
|
|||
689
web/app.html
Normal file
689
web/app.html
Normal 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
143
web/src/api-types.ts
Normal 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
691
web/src/app.ts
Normal 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();
|
||||
});
|
||||
181
web/src/pages/bot-profile.ts
Normal file
181
web/src/pages/bot-profile.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
88
web/src/pages/bots.ts
Normal file
88
web/src/pages/bots.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
71
web/src/pages/home.ts
Normal file
71
web/src/pages/home.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
104
web/src/pages/leaderboard.ts
Normal file
104
web/src/pages/leaderboard.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
99
web/src/pages/matches.ts
Normal file
99
web/src/pages/matches.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
191
web/src/pages/register.ts
Normal file
191
web/src/pages/register.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
89
web/src/router.ts
Normal file
89
web/src/router.ts
Normal 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();
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue