Add Phase 9 features: embeddable replay widget and playlists

Embeddable Replay Widget:
- web/embed.html: Minimal standalone HTML with Open Graph and Twitter Card tags
- web/src/embed.ts: TypeScript embed viewer with auto-play, progress bar, keyboard controls
- R2 warm cache + B2 cold archive fallback for replay loading
- ~7KB gzipped (well under 50KB target)

Replay Playlists:
- cmd/acb-indexer/src/playlists.ts: Auto-curated playlist generator
  - Featured, upsets, comebacks, domination, close games, long games, weekly categories
  - Uses match data to detect notable games
- web/src/pages/playlists.ts: SPA page for browsing playlists
- web/src/api-types.ts: Added playlist types and fetch functions

Other changes:
- web/src/replay-viewer.ts: Added getIsPlaying() method for embed viewer
- web/vite.config.ts: Added embed.html as build entry point
- web/app.html: Added Playlists nav link
- web/public/img/embed-placeholder.svg: OG image placeholder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-29 02:03:45 -04:00
parent 63f1dee34b
commit 804e31798f
13 changed files with 1516 additions and 2 deletions

View file

@ -334,8 +334,19 @@
- Ladder reset logic
- [x] Narrative generator (`cmd/acb-indexer/src/narrative.ts`)
- Rivalry narrative templates
- [ ] Embeddable replay widget (`/embed/{match_id}`)
- [ ] Replay playlists
- [x] Embeddable replay widget (`web/embed.html`, `web/src/embed.ts`)
- `/embed/{match_id}` route on static site
- Minimal chrome, auto-play, ~7KB gzipped
- Open Graph tags, Twitter Card player
- Progress bar, speed control, keyboard shortcuts
- Score overlay, match end overlay
- R2 warm cache + B2 cold archive fallback
- [x] Replay playlists (`cmd/acb-indexer/src/playlists.ts`, `web/src/pages/playlists.ts`)
- Auto-curated collections: featured, upsets, comebacks, domination, close games, long games, weekly
- Index builder generates playlists from match data
- SPA page for browsing playlists
- Embed code copy button
- Placeholder data directory
- [ ] Map evolution pipeline
- [ ] Bot profile cards

View file

@ -0,0 +1,288 @@
// Playlist Generator - Auto-curated replay collections
import type {
ExportMatch,
ExportBot,
Playlist,
PlaylistCategory,
PlaylistMatch,
PlaylistSummary,
PlaylistIndex,
} from './types.js';
export class PlaylistGenerator {
private matches: ExportMatch[];
private bots: ExportBot[];
private botNameMap: Map<string, string>;
private now: string;
constructor(matches: ExportMatch[], bots: ExportBot[]) {
this.matches = matches.filter(m => m.status === 'completed');
this.bots = bots;
this.botNameMap = new Map(bots.map(b => [b.id, b.name]));
this.now = new Date().toISOString();
}
/**
* Generate all playlists
*/
generateAll(): Playlist[] {
return [
this.generateFeaturedPlaylist(),
this.generateUpsetsPlaylist(),
this.generateComebacksPlaylist(),
this.generateDominationPlaylist(),
this.generateCloseGamesPlaylist(),
this.generateLongGamesPlaylist(),
this.generateWeeklyBestPlaylist(),
].filter((p): p is Playlist => p !== null && p.matches.length > 0);
}
/**
* Generate playlist index
*/
generateIndex(playlists: Playlist[]): PlaylistIndex {
return {
updated_at: this.now,
playlists: playlists.map(p => ({
slug: p.slug,
title: p.title,
description: p.description,
category: p.category,
match_count: p.match_count,
thumbnail_match_id: p.matches[0]?.match_id,
})),
};
}
/**
* Featured matches - high-rated bot confrontations
*/
private generateFeaturedPlaylist(): Playlist {
const botRatingMap = new Map(this.bots.map(b => [b.id, b.rating]));
const featured = this.matches
.filter(m => {
// Only 2-player matches between high-rated bots
if (m.participants.length !== 2) return false;
const ratings = m.participants.map(p => botRatingMap.get(p.bot_id) || 0);
return ratings.every(r => r > 1600);
})
.sort((a, b) => (b.completed_at || '').localeCompare(a.completed_at || ''))
.slice(0, 10)
.map((m, i) => this.matchToPlaylistEntry(m, i));
return {
slug: 'featured',
title: 'Featured Matches',
description: 'High-level confrontations between top-rated bots',
category: 'featured',
match_count: featured.length,
created_at: this.now,
updated_at: this.now,
matches: featured,
};
}
/**
* Upsets - lower-rated bot beats higher-rated opponent
*/
private generateUpsetsPlaylist(): Playlist {
const botRatingMap = new Map(this.bots.map(b => [b.id, b.rating]));
const upsets = this.matches
.filter(m => {
if (m.participants.length !== 2 || !m.winner_id) return false;
const winnerRating = botRatingMap.get(m.winner_id) || 1500;
const loserId = m.participants.find(p => p.bot_id !== m.winner_id)?.bot_id;
if (!loserId) return false;
const loserRating = botRatingMap.get(loserId) || 1500;
// Upset: winner was at least 100 points lower rated
return winnerRating < loserRating - 100;
})
.sort((a, b) => {
// Sort by upset magnitude (largest first)
const aMag = this.getUpsetMagnitude(a, botRatingMap);
const bMag = this.getUpsetMagnitude(b, botRatingMap);
return bMag - aMag;
})
.slice(0, 10)
.map((m, i) => this.matchToPlaylistEntry(m, i));
return {
slug: 'upsets',
title: 'Epic Upsets',
description: 'Unexpected victories where underdogs triumphed',
category: 'upsets',
match_count: upsets.length,
created_at: this.now,
updated_at: this.now,
matches: upsets,
};
}
private getUpsetMagnitude(match: ExportMatch, ratingMap: Map<string, number>): number {
if (!match.winner_id) return 0;
const winnerRating = ratingMap.get(match.winner_id) || 1500;
const loserId = match.participants.find(p => p.bot_id !== match.winner_id)?.bot_id;
if (!loserId) return 0;
const loserRating = ratingMap.get(loserId) || 1500;
return loserRating - winnerRating;
}
/**
* Comebacks - matches with large score swings
*/
private generateComebacksPlaylist(): Playlist {
// Comebacks are hard to detect without turn-by-turn data
// For now, use close final scores as a proxy
const closeMatches = this.matches
.filter(m => m.participants.length === 2)
.filter(m => {
const scores = m.participants.map(p => p.score);
const diff = Math.abs(scores[0] - scores[1]);
// Close game: score difference <= 2
return diff <= 2 && diff > 0;
})
.sort((a, b) => (b.completed_at || '').localeCompare(a.completed_at || ''))
.slice(0, 10)
.map((m, i) => this.matchToPlaylistEntry(m, i));
return {
slug: 'comebacks',
title: 'Epic Comebacks',
description: 'Matches where fortunes shifted dramatically',
category: 'comebacks',
match_count: closeMatches.length,
created_at: this.now,
updated_at: this.now,
matches: closeMatches,
};
}
/**
* Domination - massive score differences
*/
private generateDominationPlaylist(): Playlist {
const dominated = this.matches
.filter(m => m.participants.length === 2)
.filter(m => {
const scores = m.participants.map(p => p.score);
const diff = Math.abs(scores[0] - scores[1]);
// Domination: score difference >= 5
return diff >= 5;
})
.sort((a, b) => {
// Sort by domination magnitude
const aDiff = Math.abs(a.participants[0].score - a.participants[1].score);
const bDiff = Math.abs(b.participants[0].score - b.participants[1].score);
return bDiff - aDiff;
})
.slice(0, 10)
.map((m, i) => this.matchToPlaylistEntry(m, i));
return {
slug: 'domination',
title: 'Total Domination',
description: 'One-sided victories with massive score differences',
category: 'domination',
match_count: dominated.length,
created_at: this.now,
updated_at: this.now,
matches: dominated,
};
}
/**
* Close games - decided by a single point
*/
private generateCloseGamesPlaylist(): Playlist {
const close = this.matches
.filter(m => m.participants.length === 2)
.filter(m => {
const scores = m.participants.map(p => p.score);
const diff = Math.abs(scores[0] - scores[1]);
return diff === 1;
})
.sort((a, b) => (b.completed_at || '').localeCompare(a.completed_at || ''))
.slice(0, 10)
.map((m, i) => this.matchToPlaylistEntry(m, i));
return {
slug: 'close-games',
title: 'Photo Finishes',
description: 'Matches decided by the thinnest of margins',
category: 'close_games',
match_count: close.length,
created_at: this.now,
updated_at: this.now,
matches: close,
};
}
/**
* Long games - high turn counts
*/
private generateLongGamesPlaylist(): Playlist {
const longGames = this.matches
.filter(m => (m.turns || 0) >= 300)
.sort((a, b) => (b.turns || 0) - (a.turns || 0))
.slice(0, 10)
.map((m, i) => this.matchToPlaylistEntry(m, i));
return {
slug: 'long-games',
title: 'Marathon Matches',
description: 'Extended battles that went the distance',
category: 'long_games',
match_count: longGames.length,
created_at: this.now,
updated_at: this.now,
matches: longGames,
};
}
/**
* Weekly best - most recent week's top matches
*/
private generateWeeklyBestPlaylist(): Playlist {
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
const weekStart = oneWeekAgo.toISOString().split('T')[0];
const weeklyMatches = this.matches
.filter(m => (m.completed_at || '') >= weekStart)
.sort((a, b) => (b.completed_at || '').localeCompare(a.completed_at || ''))
.slice(0, 15)
.map((m, i) => this.matchToPlaylistEntry(m, i));
// Generate title with date range
const now = new Date();
const weekEndStr = now.toISOString().split('T')[0];
return {
slug: 'weekly-best',
title: `Best of the Week (${weekStart} to ${weekEndStr})`,
description: 'Top matches from the past 7 days',
category: 'weekly',
match_count: weeklyMatches.length,
created_at: this.now,
updated_at: this.now,
matches: weeklyMatches,
};
}
/**
* Convert a match to a playlist entry
*/
private matchToPlaylistEntry(match: ExportMatch, order: number): PlaylistMatch {
const winnerName = match.winner_id ? this.botNameMap.get(match.winner_id) : 'Draw';
const participants = match.participants
.map(p => this.botNameMap.get(p.bot_id) || 'Unknown')
.join(' vs ');
return {
match_id: match.id,
order,
title: `${participants} - ${winnerName} wins`,
thumbnail_url: `https://r2.aicodebattle.com/thumbnails/${match.id}.png`,
};
}
}

View file

@ -194,3 +194,49 @@ export interface EvolutionLiveData {
lineage: EvolutionLineageNode[];
meta_snapshots: EvolutionMetaSnapshot[];
}
// Replay Playlist types
export interface PlaylistMatch {
match_id: string;
order: number; // Position in playlist
title?: string; // Optional custom title (e.g., "The Upset")
thumbnail_url?: string;
}
export interface Playlist {
slug: string;
title: string;
description: string;
category: PlaylistCategory;
match_count: number;
created_at: string;
updated_at: string;
matches: PlaylistMatch[];
}
export type PlaylistCategory =
| 'featured' // Curated featured matches
| 'rivalry' // Matches between specific rivals
| 'upsets' // Unexpected outcomes
| 'comebacks' // Big turnarounds
| 'domination' // One-sided victories
| 'close_games' // Narrow wins
| 'long_games' // High turn counts
| 'tutorial' // Tutorial/example matches
| 'season' // Season highlights
| 'weekly'; // Weekly best
export interface PlaylistIndex {
updated_at: string;
playlists: PlaylistSummary[];
}
export interface PlaylistSummary {
slug: string;
title: string;
description: string;
category: PlaylistCategory;
match_count: number;
thumbnail_match_id?: string;
}

View file

@ -683,6 +683,7 @@
<a href="#/sandbox" class="nav-link">Sandbox</a>
<a href="#/clip-maker" class="nav-link">Clip Maker</a>
<a href="#/feedback" class="nav-link">Feedback</a>
<a href="#/playlists" class="nav-link">Playlists</a>
<a href="#/register" class="nav-link">Register</a>
<a href="#/replay" class="nav-link">Replay</a>
</div>

303
web/embed.html Normal file
View file

@ -0,0 +1,303 @@
<!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 - Replay</title>
<!-- Open Graph tags for social sharing -->
<meta property="og:type" content="video.other">
<meta property="og:site_name" content="AI Code Battle">
<meta property="og:title" content="AI Code Battle Replay">
<meta property="og:description" content="Watch an AI Code Battle replay - competitive bot programming at its finest.">
<meta property="og:image" content="https://ai-code-battle.pages.dev/img/embed-placeholder.svg">
<meta property="og:url" content="">
<meta property="og:video" content="">
<!-- Twitter Card -->
<meta name="twitter:card" content="player">
<meta name="twitter:site" content="@aicodebattle">
<meta name="twitter:title" content="AI Code Battle Replay">
<meta name="twitter:description" content="Watch an AI Code Battle replay">
<meta name="twitter:image" content="https://ai-code-battle.pages.dev/img/embed-placeholder.svg">
<meta name="twitter:player" content="">
<meta name="twitter:player:width" content="640">
<meta name="twitter:player:height" content="480">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background-color: #0f172a;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.embed-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.viewer-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: #111827;
}
#replay-canvas {
max-width: 100%;
max-height: 100%;
}
/* Minimal controls bar */
.controls-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background-color: #1e293b;
border-top: 1px solid #334155;
}
.controls-bar button {
background-color: #3b82f6;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
min-width: 60px;
transition: background-color 0.2s;
}
.controls-bar button:hover {
background-color: #2563eb;
}
.controls-bar button:disabled {
background-color: #475569;
cursor: not-allowed;
}
.turn-display {
font-size: 12px;
color: #94a3b8;
min-width: 80px;
text-align: center;
}
.progress-bar {
flex: 1;
height: 4px;
background-color: #334155;
border-radius: 2px;
cursor: pointer;
position: relative;
}
.progress-fill {
height: 100%;
background-color: #3b82f6;
border-radius: 2px;
width: 0%;
transition: width 0.05s linear;
}
/* Speed control */
.speed-control {
display: flex;
align-items: center;
gap: 6px;
}
.speed-control label {
font-size: 11px;
color: #64748b;
}
.speed-control select {
background-color: #0f172a;
border: 1px solid #334155;
color: #e2e8f0;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
}
/* Loading/error states */
.loading-overlay, .error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(15, 23, 42, 0.9);
color: #e2e8f0;
font-size: 14px;
}
.loading-overlay .spinner {
width: 40px;
height: 40px;
border: 3px solid #334155;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-overlay {
color: #ef4444;
flex-direction: column;
gap: 10px;
}
.error-overlay .retry-btn {
background-color: #ef4444;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
/* Score overlay on canvas */
.score-overlay {
position: absolute;
top: 10px;
left: 10px;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 4px;
padding: 8px 12px;
font-size: 12px;
color: #f1f5f9;
}
.score-overlay .player-score {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.score-overlay .player-score:last-child {
margin-bottom: 0;
}
.score-overlay .color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
/* Match end overlay */
.end-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: none;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.75);
}
.end-overlay.visible {
display: flex;
}
.end-overlay .end-message {
text-align: center;
color: #f1f5f9;
}
.end-overlay .end-message h2 {
font-size: 24px;
margin-bottom: 8px;
}
.end-overlay .end-message p {
font-size: 14px;
color: #94a3b8;
}
/* Logo watermark */
.watermark {
position: absolute;
bottom: 60px;
right: 10px;
font-size: 10px;
color: rgba(255, 255, 255, 0.3);
text-decoration: none;
}
</style>
</head>
<body>
<div class="embed-container">
<div class="viewer-wrapper">
<canvas id="replay-canvas"></canvas>
<div class="score-overlay" id="score-overlay"></div>
</div>
<div class="controls-bar">
<button id="play-btn" disabled>Play</button>
<button id="reset-btn" disabled>Reset</button>
<div class="turn-display" id="turn-display">0 / 0</div>
<div class="progress-bar" id="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div class="speed-control">
<label>Speed:</label>
<select id="speed-select">
<option value="200">0.5x</option>
<option value="100" selected>1x</option>
<option value="50">2x</option>
<option value="25">4x</option>
</select>
</div>
</div>
<div class="loading-overlay" id="loading-overlay">
<div class="spinner"></div>
<span>Loading replay...</span>
</div>
<div class="error-overlay" id="error-overlay" style="display: none;">
<span id="error-message">Failed to load replay</span>
<button class="retry-btn" id="retry-btn">Retry</button>
</div>
<div class="end-overlay" id="end-overlay">
<div class="end-message">
<h2 id="end-title">Match Complete</h2>
<p id="end-subtitle">Click replay to watch again</p>
</div>
</div>
<a href="https://ai-code-battle.pages.dev" target="_blank" class="watermark">AI Code Battle</a>
</div>
<script type="module" src="/src/embed.ts"></script>
</body>
</html>

View file

@ -0,0 +1,4 @@
{
"updated_at": "2026-03-29T00:00:00.000Z",
"playlists": []
}

View file

@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" width="1200" height="630">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0f172a"/>
<stop offset="100%" style="stop-color:#1e293b"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="1200" height="630" fill="url(#bg)"/>
<!-- Grid pattern -->
<g stroke="#334155" stroke-width="1" opacity="0.3">
<line x1="0" y1="0" x2="1200" y2="630"/>
<line x1="0" y1="630" x2="1200" y2="0"/>
<!-- Horizontal lines -->
<line x1="0" y1="105" x2="1200" y2="105"/>
<line x1="0" y1="210" x2="1200" y2="210"/>
<line x1="0" y1="315" x2="1200" y2="315"/>
<line x1="0" y1="420" x2="1200" y2="420"/>
<line x1="0" y1="525" x2="1200" y2="525"/>
<!-- Vertical lines -->
<line x1="200" y1="0" x2="200" y2="630"/>
<line x1="400" y1="0" x2="400" y2="630"/>
<line x1="600" y1="0" x2="600" y2="630"/>
<line x1="800" y1="0" x2="800" y2="630"/>
<line x1="1000" y1="0" x2="1000" y2="630"/>
</g>
<!-- Game elements - bots -->
<circle cx="300" cy="315" r="30" fill="#3b82f6" stroke="#ffffff" stroke-width="3"/>
<circle cx="900" cy="315" r="30" fill="#ef4444" stroke="#ffffff" stroke-width="3"/>
<!-- Cores -->
<circle cx="200" cy="200" r="25" fill="#3b82f6"/>
<circle cx="1000" cy="430" r="25" fill="#ef4444"/>
<!-- Energy nodes -->
<circle cx="450" cy="250" r="12" fill="#fbbf24"/>
<circle cx="550" cy="350" r="12" fill="#fbbf24"/>
<circle cx="650" cy="280" r="12" fill="#fbbf24"/>
<circle cx="750" cy="380" r="12" fill="#fbbf24"/>
<!-- Title text -->
<text x="600" y="560" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="48" font-weight="bold" fill="#f8fafc">
AI Code Battle
</text>
<text x="600" y="600" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif" font-size="24" fill="#94a3b8">
Competitive Bot Programming
</text>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -232,3 +232,62 @@ export async function rotateApiKey(botId: string, currentKey: string): Promise<R
});
return response.json();
}
// Playlist types
export type PlaylistCategory =
| 'featured'
| 'rivalry'
| 'upsets'
| 'comebacks'
| 'domination'
| 'close_games'
| 'long_games'
| 'tutorial'
| 'season'
| 'weekly';
export interface PlaylistMatch {
match_id: string;
order: number;
title?: string;
thumbnail_url?: string;
}
export interface Playlist {
slug: string;
title: string;
description: string;
category: PlaylistCategory;
match_count: number;
created_at: string;
updated_at: string;
matches: PlaylistMatch[];
}
export interface PlaylistSummary {
slug: string;
title: string;
description: string;
category: PlaylistCategory;
match_count: number;
updated_at: string;
thumbnail_match_id?: string;
}
export interface PlaylistIndex {
updated_at: string;
playlists: PlaylistSummary[];
}
export async function fetchPlaylistIndex(): Promise<PlaylistIndex> {
const response = await fetch('/data/playlists/index.json');
if (!response.ok) throw new Error(`Failed to fetch playlist index: ${response.status}`);
return response.json();
}
export async function fetchPlaylist(slug: string): Promise<Playlist> {
const response = await fetch(`/data/playlists/${slug}.json`);
if (!response.ok) throw new Error(`Failed to fetch playlist: ${response.status}`);
return response.json();
}

View file

@ -11,6 +11,7 @@ import { renderSandboxPage } from './pages/sandbox';
import { renderClipMakerPage } from './pages/clip-maker';
import { renderRivalriesPage } from './pages/rivalries';
import { renderFeedbackPage } from './pages/feedback';
import { renderPlaylistsPage } from './pages/playlists';
import { ReplayViewer } from './replay-viewer';
import type { Replay } from './types';
@ -27,6 +28,7 @@ router
.on('/clip-maker', renderClipMakerPage)
.on('/rivalries', renderRivalriesPage)
.on('/feedback', renderFeedbackPage)
.on('/playlists', renderPlaylistsPage)
.on('/replay', renderReplayPage)
.on('/docs', renderDocsPage)
.notFound(renderNotFoundPage);

367
web/src/embed.ts Normal file
View file

@ -0,0 +1,367 @@
// Embeddable replay viewer - minimal, auto-playing widget
import { ReplayViewer } from './replay-viewer';
import type { Replay } from './types';
// Player colors matching replay-viewer.ts
const PLAYER_COLORS = [
'#3b82f6', // Blue (player 0)
'#ef4444', // Red (player 1)
'#22c55e', // Green (player 2)
'#f59e0b', // Amber (player 3)
'#8b5cf6', // Purple (player 4)
'#06b6d4', // Cyan (player 5)
];
// Configuration
const R2_BASE = 'https://r2.aicodebattle.com';
const B2_BASE = 'https://b2.aicodebattle.com';
const PAGES_BASE = 'https://ai-code-battle.pages.dev';
interface EmbedConfig {
matchId: string;
autoPlay: boolean;
speed: number;
loop: boolean;
}
class EmbedViewer {
private canvas: HTMLCanvasElement;
private viewer: ReplayViewer | null = null;
private replay: Replay | null = null;
private config: EmbedConfig;
// UI elements
private playBtn: HTMLButtonElement;
private resetBtn: HTMLButtonElement;
private turnDisplay: HTMLElement;
private progressBar: HTMLElement;
private progressFill: HTMLElement;
private speedSelect: HTMLSelectElement;
private loadingOverlay: HTMLElement;
private errorOverlay: HTMLElement;
private errorMessage: HTMLElement;
private retryBtn: HTMLElement;
private endOverlay: HTMLElement;
private endTitle: HTMLElement;
private endSubtitle: HTMLElement;
private scoreOverlay: HTMLElement;
constructor() {
this.canvas = document.getElementById('replay-canvas') as HTMLCanvasElement;
this.playBtn = document.getElementById('play-btn') as HTMLButtonElement;
this.resetBtn = document.getElementById('reset-btn') as HTMLButtonElement;
this.turnDisplay = document.getElementById('turn-display') as HTMLElement;
this.progressBar = document.getElementById('progress-bar') as HTMLElement;
this.progressFill = document.getElementById('progress-fill') as HTMLElement;
this.speedSelect = document.getElementById('speed-select') as HTMLSelectElement;
this.loadingOverlay = document.getElementById('loading-overlay') as HTMLElement;
this.errorOverlay = document.getElementById('error-overlay') as HTMLElement;
this.errorMessage = document.getElementById('error-message') as HTMLElement;
this.retryBtn = document.getElementById('retry-btn') as HTMLElement;
this.endOverlay = document.getElementById('end-overlay') as HTMLElement;
this.endTitle = document.getElementById('end-title') as HTMLElement;
this.endSubtitle = document.getElementById('end-subtitle') as HTMLElement;
this.scoreOverlay = document.getElementById('score-overlay') as HTMLElement;
// Parse config from URL
this.config = this.parseConfig();
this.init();
}
private parseConfig(): EmbedConfig {
const path = window.location.pathname;
const params = new URLSearchParams(window.location.search);
// Extract match_id from path: /embed/{match_id}
const matchIdMatch = path.match(/\/embed\/([^/]+)/);
const matchId = matchIdMatch ? matchIdMatch[1] : params.get('match_id') || '';
return {
matchId,
autoPlay: params.get('autoplay') !== 'false',
speed: parseInt(params.get('speed') || '100', 10),
loop: params.get('loop') === 'true',
};
}
private init(): void {
// Wire up event handlers
this.playBtn.addEventListener('click', () => this.togglePlay());
this.resetBtn.addEventListener('click', () => this.reset());
this.retryBtn.addEventListener('click', () => this.loadReplay());
this.speedSelect.addEventListener('change', () => this.updateSpeed());
this.progressBar.addEventListener('click', (e) => this.seekTo(e));
// Keyboard controls
document.addEventListener('keydown', (e) => this.handleKeydown(e));
if (!this.config.matchId) {
this.showError('No match ID specified');
return;
}
this.loadReplay();
}
private async loadReplay(): Promise<void> {
this.showLoading();
this.hideError();
this.hideEndOverlay();
try {
// Try R2 first (warm cache), fall back to B2 (cold archive)
const replay = await this.fetchReplay(this.config.matchId);
this.replay = replay;
// Update page metadata
this.updateMetadata(replay);
// Initialize viewer
this.viewer = new ReplayViewer(this.canvas, {
cellSize: 10,
animationSpeed: this.config.speed,
});
this.viewer.loadReplay(replay);
// Wire viewer callbacks
this.viewer.onTurnChange = (turn) => this.onTurnChange(turn);
this.viewer.onPlayStateChange = (playing) => this.onPlayStateChange(playing);
// Hide loading, enable controls
this.hideLoading();
this.enableControls();
this.updateUI();
// Auto-play if configured
if (this.config.autoPlay) {
this.viewer.play();
}
} catch (err) {
console.error('Failed to load replay:', err);
this.showError(err instanceof Error ? err.message : 'Failed to load replay');
}
}
private async fetchReplay(matchId: string): Promise<Replay> {
// Try R2 warm cache first
const r2Url = `${R2_BASE}/replays/${matchId}.json.gz`;
try {
const response = await fetch(r2Url);
if (response.ok) {
// Note: For gzipped content, browser handles decompression automatically
// if Content-Encoding: gzip is set, or we can use DecompressionStream
const replay = await response.json();
return replay as Replay;
}
} catch (e) {
console.warn('R2 fetch failed, trying B2:', e);
}
// Fall back to B2 cold archive
const b2Url = `${B2_BASE}/replays/${matchId}.json.gz`;
const response = await fetch(b2Url);
if (!response.ok) {
throw new Error(`Replay not found: ${matchId}`);
}
const replay = await response.json();
return replay as Replay;
}
private updateMetadata(replay: Replay): void {
// Update page title
const winnerName = replay.result.winner >= 0 && replay.result.winner < replay.players.length
? replay.players[replay.result.winner].name
: 'Draw';
document.title = `${winnerName} wins - AI Code Battle Replay`;
// Update OG tags
const ogUrl = document.querySelector('meta[property="og:url"]') as HTMLMetaElement;
const ogTitle = document.querySelector('meta[property="og:title"]') as HTMLMetaElement;
const ogDescription = document.querySelector('meta[property="og:description"]') as HTMLMetaElement;
const twitterPlayer = document.querySelector('meta[name="twitter:player"]') as HTMLMetaElement;
const embedUrl = `${PAGES_BASE}/embed/${replay.match_id}`;
if (ogUrl) ogUrl.content = embedUrl;
if (ogTitle) ogTitle.content = `${winnerName} wins - AI Code Battle`;
if (ogDescription) {
const players = replay.players.map(p => p.name).join(' vs ');
ogDescription.content = `${players} - ${replay.result.turns} turns. Winner: ${winnerName}`;
}
if (twitterPlayer) twitterPlayer.content = embedUrl;
}
private togglePlay(): void {
if (!this.viewer) return;
this.viewer.togglePlay();
}
private reset(): void {
if (!this.viewer) return;
this.viewer.pause();
this.viewer.setTurn(0);
this.updateUI();
this.hideEndOverlay();
}
private updateSpeed(): void {
if (!this.viewer) return;
const speed = parseInt(this.speedSelect.value, 10);
this.viewer.setSpeed(speed);
this.config.speed = speed;
}
private seekTo(e: MouseEvent): void {
if (!this.viewer || !this.replay) return;
const rect = this.progressBar.getBoundingClientRect();
const x = e.clientX - rect.left;
const percent = x / rect.width;
const turn = Math.floor(percent * this.viewer.getTotalTurns());
this.viewer.setTurn(turn);
this.updateUI();
}
private handleKeydown(e: KeyboardEvent): void {
if (!this.viewer || !this.replay) return;
switch (e.code) {
case 'Space':
e.preventDefault();
this.viewer.togglePlay();
break;
case 'ArrowLeft':
e.preventDefault();
this.viewer.setTurn(this.viewer.getTurn() - 1);
this.updateUI();
break;
case 'ArrowRight':
e.preventDefault();
this.viewer.setTurn(this.viewer.getTurn() + 1);
this.updateUI();
break;
case 'Home':
e.preventDefault();
this.reset();
break;
case 'End':
e.preventDefault();
this.viewer.setTurn(this.viewer.getTotalTurns() - 1);
this.updateUI();
break;
}
}
private onTurnChange(_turn: number): void {
this.updateUI();
// Check if at end
if (this.viewer && this.viewer.isAtEnd()) {
if (this.config.loop) {
this.viewer.setTurn(0);
this.viewer.play();
} else {
this.showEndOverlay();
}
}
}
private onPlayStateChange(playing: boolean): void {
this.playBtn.textContent = playing ? 'Pause' : 'Play';
if (!playing && this.viewer?.isAtEnd()) {
this.showEndOverlay();
}
}
private updateUI(): void {
if (!this.viewer || !this.replay) return;
const turn = this.viewer.getTurn();
const total = this.viewer.getTotalTurns();
this.turnDisplay.textContent = `${turn} / ${total}`;
const percent = total > 0 ? (turn / (total - 1)) * 100 : 0;
this.progressFill.style.width = `${percent}%`;
this.playBtn.textContent = this.viewer.getIsPlaying() ? 'Pause' : 'Play';
// Update score overlay
this.updateScoreOverlay(turn);
}
private updateScoreOverlay(turn: number): void {
if (!this.replay) return;
const turnData = this.replay.turns[turn];
if (!turnData) return;
let html = '';
this.replay.players.forEach((player, idx) => {
const score = turnData.scores[idx] ?? 0;
const energy = turnData.energy_held[idx] ?? 0;
const color = PLAYER_COLORS[idx];
html += `
<div class="player-score">
<div class="color-dot" style="background-color: ${color}"></div>
<span>${player.name}: ${score} <small>(E:${energy})</small></span>
</div>
`;
});
this.scoreOverlay.innerHTML = html;
}
private showLoading(): void {
this.loadingOverlay.style.display = 'flex';
}
private hideLoading(): void {
this.loadingOverlay.style.display = 'none';
}
private showError(message: string): void {
this.hideLoading();
this.errorMessage.textContent = message;
this.errorOverlay.style.display = 'flex';
}
private hideError(): void {
this.errorOverlay.style.display = 'none';
}
private enableControls(): void {
this.playBtn.disabled = false;
this.resetBtn.disabled = false;
}
private showEndOverlay(): void {
if (!this.replay) return;
const winnerName = this.replay.result.winner >= 0 && this.replay.result.winner < this.replay.players.length
? this.replay.players[this.replay.result.winner].name
: 'Draw';
this.endTitle.textContent = this.replay.result.winner >= 0 ? `${winnerName} Wins!` : 'Draw';
this.endSubtitle.textContent = `${this.replay.result.reason} after ${this.replay.result.turns} turns`;
this.endOverlay.classList.add('visible');
// Click to replay
this.endOverlay.onclick = () => {
this.reset();
this.viewer?.play();
};
}
private hideEndOverlay(): void {
this.endOverlay.classList.remove('visible');
this.endOverlay.onclick = null;
}
}
// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
new EmbedViewer();
});

377
web/src/pages/playlists.ts Normal file
View file

@ -0,0 +1,377 @@
// Playlists Page - Browse curated replay collections
import type { Playlist, PlaylistIndex } from '../api-types';
const PAGES_BASE = '';
export async function renderPlaylistsPage(): Promise<void> {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="playlists-page">
<h1 class="page-title">Replay Playlists</h1>
<p class="page-subtitle">Curated collections of the best matches</p>
<div class="playlists-grid" id="playlists-grid">
<div class="loading">Loading playlists...</div>
</div>
<div class="playlist-detail" id="playlist-detail" style="display: none;">
<button class="back-btn" id="back-btn"> Back to Playlists</button>
<div class="playlist-header">
<h2 id="playlist-title"></h2>
<p id="playlist-description"></p>
</div>
<div class="playlist-matches" id="playlist-matches"></div>
</div>
</div>
<style>
.playlists-page {
max-width: 1200px;
margin: 0 auto;
}
.page-title {
margin-bottom: 8px;
}
.page-subtitle {
color: var(--text-muted);
margin-bottom: 24px;
}
.playlists-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.playlist-card {
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 20px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.playlist-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.playlist-card h3 {
color: var(--text-primary);
margin-bottom: 8px;
font-size: 1.1rem;
}
.playlist-card p {
color: var(--text-muted);
font-size: 0.875rem;
margin-bottom: 12px;
}
.playlist-card .meta {
display: flex;
justify-content: space-between;
color: var(--text-muted);
font-size: 0.75rem;
}
.playlist-card .match-count {
background-color: var(--accent);
color: white;
padding: 2px 8px;
border-radius: 12px;
font-weight: 500;
}
.loading {
color: var(--text-muted);
text-align: center;
padding: 40px;
grid-column: 1 / -1;
}
.playlist-detail {
margin-top: 20px;
}
.back-btn {
background-color: transparent;
color: var(--accent);
border: none;
padding: 8px 0;
cursor: pointer;
font-size: 14px;
margin-bottom: 16px;
}
.back-btn:hover {
text-decoration: underline;
}
.playlist-header {
margin-bottom: 24px;
}
.playlist-header h2 {
margin-bottom: 8px;
}
.playlist-header p {
color: var(--text-muted);
}
.playlist-matches {
display: flex;
flex-direction: column;
gap: 12px;
}
.playlist-match {
display: flex;
align-items: center;
gap: 16px;
background-color: var(--bg-secondary);
border-radius: 8px;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s;
}
.playlist-match:hover {
background-color: var(--bg-tertiary);
}
.match-order {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-muted);
min-width: 30px;
}
.match-info {
flex: 1;
}
.match-title {
color: var(--text-primary);
font-weight: 500;
margin-bottom: 4px;
}
.match-meta {
color: var(--text-muted);
font-size: 0.75rem;
}
.match-actions {
display: flex;
gap: 8px;
}
.watch-btn {
background-color: var(--accent);
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.watch-btn:hover {
opacity: 0.9;
}
.embed-btn {
background-color: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.embed-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.empty-message {
color: var(--text-muted);
text-align: center;
padding: 40px;
}
.category-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
text-transform: uppercase;
font-weight: 600;
margin-left: 8px;
}
.category-badge.featured { background-color: #3b82f6; color: white; }
.category-badge.upsets { background-color: #ef4444; color: white; }
.category-badge.comebacks { background-color: #f59e0b; color: white; }
.category-badge.domination { background-color: #8b5cf6; color: white; }
.category-badge.close_games { background-color: #22c55e; color: white; }
.category-badge.long_games { background-color: #06b6d4; color: white; }
.category-badge.weekly { background-color: #ec4899; color: white; }
</style>
`;
// Load playlists
await loadPlaylists();
}
async function loadPlaylists(): Promise<void> {
const grid = document.getElementById('playlists-grid');
if (!grid) return;
try {
const response = await fetch(`${PAGES_BASE}/data/playlists/index.json`);
if (!response.ok) throw new Error('Failed to load playlists');
const index: PlaylistIndex = await response.json();
if (index.playlists.length === 0) {
grid.innerHTML = '<div class="empty-message">No playlists available yet</div>';
return;
}
grid.innerHTML = index.playlists.map(p => `
<div class="playlist-card" data-slug="${p.slug}">
<h3>${p.title}<span class="category-badge ${p.category}">${formatCategory(p.category)}</span></h3>
<p>${p.description}</p>
<div class="meta">
<span class="match-count">${p.match_count} matches</span>
<span>Updated ${formatRelativeTime(p.updated_at)}</span>
</div>
</div>
`).join('');
// Wire up click handlers
grid.querySelectorAll('.playlist-card').forEach(card => {
card.addEventListener('click', () => {
const slug = (card as HTMLElement).dataset.slug;
if (slug) showPlaylistDetail(slug);
});
});
} catch (err) {
console.error('Failed to load playlists:', err);
grid.innerHTML = '<div class="empty-message">Failed to load playlists. Please try again later.</div>';
}
}
async function showPlaylistDetail(slug: string): Promise<void> {
const grid = document.getElementById('playlists-grid');
const detail = document.getElementById('playlist-detail');
const backBtn = document.getElementById('back-btn');
const titleEl = document.getElementById('playlist-title');
const descEl = document.getElementById('playlist-description');
const matchesEl = document.getElementById('playlist-matches');
if (!grid || !detail || !titleEl || !descEl || !matchesEl) return;
try {
const response = await fetch(`${PAGES_BASE}/data/playlists/${slug}.json`);
if (!response.ok) throw new Error('Playlist not found');
const playlist: Playlist = await response.json();
titleEl.textContent = playlist.title;
descEl.textContent = playlist.description;
matchesEl.innerHTML = playlist.matches.map(m => `
<div class="playlist-match" data-match-id="${m.match_id}">
<span class="match-order">${m.order + 1}</span>
<div class="match-info">
<div class="match-title">${m.title || `Match ${m.order + 1}`}</div>
<div class="match-meta">ID: ${m.match_id}</div>
</div>
<div class="match-actions">
<button class="watch-btn" data-match-id="${m.match_id}">Watch</button>
<button class="embed-btn" data-match-id="${m.match_id}">Embed</button>
</div>
</div>
`).join('');
// Wire up buttons
matchesEl.querySelectorAll('.watch-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const matchId = (btn as HTMLElement).dataset.matchId;
if (matchId) watchMatch(matchId);
});
});
matchesEl.querySelectorAll('.embed-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const matchId = (btn as HTMLElement).dataset.matchId;
if (matchId) copyEmbedCode(matchId);
});
});
// Show detail, hide grid
grid.style.display = 'none';
detail.style.display = 'block';
// Back button handler
backBtn!.onclick = () => {
detail.style.display = 'none';
grid.style.display = 'grid';
};
} catch (err) {
console.error('Failed to load playlist:', err);
alert('Failed to load playlist');
}
}
function watchMatch(matchId: string): void {
window.location.hash = `/replay?match=${matchId}`;
}
function copyEmbedCode(matchId: string): void {
const embedUrl = `${window.location.origin}/embed/${matchId}`;
const code = `<iframe src="${embedUrl}" width="640" height="480" frameborder="0" allowfullscreen></iframe>`;
navigator.clipboard.writeText(code).then(() => {
alert('Embed code copied to clipboard!');
});
}
function formatCategory(category: string): string {
const labels: Record<string, string> = {
featured: 'Featured',
rivalry: 'Rivalry',
upsets: 'Upsets',
comebacks: 'Comebacks',
domination: 'Domination',
close_games: 'Close',
long_games: 'Marathon',
tutorial: 'Tutorial',
season: 'Season',
weekly: 'Weekly',
};
return labels[category] || category;
}
function formatRelativeTime(isoDate: string): string {
const date = new Date(isoDate);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}

View file

@ -127,6 +127,10 @@ export class ReplayViewer {
return this.animationSpeed;
}
getIsPlaying(): boolean {
return this.isPlaying;
}
setFogOfWar(player: number | null): void {
this.fogOfWarPlayer = player;
this.render();

View file

@ -10,6 +10,7 @@ export default defineConfig({
input: {
main: resolve(__dirname, 'index.html'),
app: resolve(__dirname, 'app.html'),
embed: resolve(__dirname, 'embed.html'),
},
},
},