ai-code-battle/web/src/embed.ts
jedarden 91d807cec2 feat(web,cmd): enhance evolution dashboard, series/seasons pages, and matchmaker
- Evolution page: live polling (10s), activity feed, candidate tracking,
  statistics section, island overview with live.json schema
- Series page: detailed series view with game-by-game results
- Seasons page: season list with status and champion display
- Predictions page: enhanced prediction UI with open matches
- API types: add CycleInfo, Candidate, ActivityEntry, Totals for live.json
- Embed: improved embeddable replay widget
- Mobile CSS: responsive breakpoints and bottom tab bar
- Exporter: enhanced live.json generation with full cycle/candidate data
- Matchmaker: series scheduling support with config
- Worker: additional database queries for series/season data

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 13:42:20 -04:00

377 lines
12 KiB
TypeScript

// 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;
viewMode: 'standard' | 'dots' | 'voronoi' | 'influence';
}
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') || '';
// Parse view mode - default to 'influence' (territory view) for homepage embeds
const viewModeParam = params.get('view');
const viewMode: 'standard' | 'dots' | 'voronoi' | 'influence' =
viewModeParam === 'standard' || viewModeParam === 'dots' || viewModeParam === 'voronoi' || viewModeParam === 'influence'
? viewModeParam
: 'influence'; // Default to territory view for homepage
return {
matchId,
autoPlay: params.get('autoplay') !== 'false',
speed: parseInt(params.get('speed') || '100', 10),
loop: params.get('loop') === 'true',
viewMode,
};
}
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,
viewMode: this.config.viewMode,
});
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();
});