diff --git a/web/pages.json b/web/pages.json index 5f74194..41bda67 100644 --- a/web/pages.json +++ b/web/pages.json @@ -14,8 +14,8 @@ "replay_viewer": "replay.html" }, "data_paths": { - "pages_data": "https://aicodebattle.com/data", - "r2_data": "https://r2.aicodebattle.com" + "pages_data": "/data", + "replay_data": "/data/replays (gzipped static assets bundled into the deploy by the index builder)" }, "deployment": { "method": "wrangler pages deploy", diff --git a/web/src/api-types.ts b/web/src/api-types.ts index afed243..a27cf42 100644 --- a/web/src/api-types.ts +++ b/web/src/api-types.ts @@ -241,13 +241,13 @@ export async function registerBot(request: RegisterRequest): Promise { // Evolution data changes every ~10s — bypass SWR, always fetch fresh - const response = await fetch(`${R2_BASE_URL}/evolution/live.json`); + const response = await fetch(`${DATA_BASE_URL}/evolution/live.json`); if (!response.ok) throw new Error(`Failed to fetch evolution data: ${response.status}`); return response.json(); } diff --git a/web/src/components/bot-card.ts b/web/src/components/bot-card.ts index 9784fcc..e85e043 100644 --- a/web/src/components/bot-card.ts +++ b/web/src/components/bot-card.ts @@ -392,7 +392,7 @@ function wrapText( * Generate a shareable bot card URL */ export function getBotCardURL(botId: string): string { - return `/r2/cards/${botId}.png`; + return `/data/cards/${botId}.png`; } /** diff --git a/web/src/components/playlist-carousel.ts b/web/src/components/playlist-carousel.ts index 55f3976..74aa480 100644 --- a/web/src/components/playlist-carousel.ts +++ b/web/src/components/playlist-carousel.ts @@ -4,6 +4,7 @@ import type { Playlist, PlaylistMatch } from '../api-types'; import type { Replay } from '../types'; +import { fetchReplayFromUrl, replayUrl } from '../lib/replay-data'; import { computeAllDensities, computeSpeedSchedule, @@ -43,8 +44,6 @@ export interface CarouselOptions { const DEFAULT_AUTO_ADVANCE_DELAY = 3000; const METADATA_PANEL_WIDTH = 280; const TRANSITION_MS = 300; -const R2_BASE = '/r2'; -const B2_FALLBACK = 'https://b2.aicodebattle.com'; const SWIPE_THRESHOLD = 50; // min px to trigger advance const VELOCITY_THRESHOLD = 0.3; // px/ms — fast flick triggers even below threshold const REDUCED_MOTION = typeof window !== 'undefined' @@ -373,19 +372,7 @@ export class PlaylistCarousel { } private async fetchReplay(matchId: string): Promise { - const urls = [ - `${R2_BASE}/replays/${matchId}.json.gz`, - `${B2_FALLBACK}/replays/${matchId}.json.gz`, - ]; - for (const url of urls) { - try { - const resp = await fetch(url); - if (resp.ok) return await resp.json(); - } catch { /* try next */ } - } - const resp = await fetch(`/replays/${matchId}.json.gz`); - if (!resp.ok) throw new Error(`Failed to fetch replay ${matchId}`); - return resp.json(); + return fetchReplayFromUrl(replayUrl(matchId)); } private preloadNext(index: number): void { @@ -435,7 +422,7 @@ export class PlaylistCarousel { btn.addEventListener('click', () => { const id = (btn as HTMLElement).dataset.matchId!; this.destroy(); - window.location.hash = `/watch/replay?url=/replays/${id}.json.gz`; + window.location.hash = `/watch/replay?url=${replayUrl(id)}`; }); } } diff --git a/web/src/embed.ts b/web/src/embed.ts index 964a12e..c30a921 100644 --- a/web/src/embed.ts +++ b/web/src/embed.ts @@ -2,6 +2,7 @@ import { ReplayViewer } from './replay-viewer'; import type { Replay } from './types'; import { fetchCommentary } from './api-types'; +import { fetchReplayFromUrl, replayUrl } from './lib/replay-data'; // Player colors matching replay-viewer.ts const PLAYER_COLORS = [ @@ -14,8 +15,6 @@ const PLAYER_COLORS = [ ]; // Configuration -const R2_BASE = '/r2'; -const B2_BASE = 'https://b2.aicodebattle.com'; const PAGES_BASE = 'https://ai-code-battle.pages.dev'; interface EmbedConfig { @@ -178,28 +177,8 @@ class EmbedViewer { } private async fetchReplay(matchId: string): Promise { - // 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; + // Replays are gzipped static assets bundled into the Pages deploy under /data/replays/. + return fetchReplayFromUrl(replayUrl(matchId)); } private async fetchDemoReplay(): Promise { diff --git a/web/src/lib/ambient.ts b/web/src/lib/ambient.ts index 9cdfc36..70059dc 100644 --- a/web/src/lib/ambient.ts +++ b/web/src/lib/ambient.ts @@ -223,7 +223,7 @@ export function startAmbientPolling(intervalMs = 30_000): void { try { // Fetch evolution live data for generation changes const evoResp = await fetch( - '/r2/evolution/live.json', + '/data/evolution/live.json', ); if (evoResp.ok) { const evoData: LiveJSON = await evoResp.json(); diff --git a/web/src/lib/replay-data.ts b/web/src/lib/replay-data.ts new file mode 100644 index 0000000..59f9d74 --- /dev/null +++ b/web/src/lib/replay-data.ts @@ -0,0 +1,35 @@ +// Replay data access. +// +// Replays are served as gzipped static assets under /data/replays/ on Cloudflare +// Pages: B2 is the private cold archive, and the index-builder bundles the warm +// set into the Pages deploy (cmd/acb-index-builder bundleWarmReplays). Because +// Pages serves the .json.gz bytes verbatim (no Content-Encoding), the browser +// must gunzip them with DecompressionStream. +import type { Replay } from '../types'; + +/** Base path for replay assets bundled into the Pages deploy. */ +export const REPLAY_BASE = '/data/replays'; + +/** Same-origin URL for a replay by match ID. */ +export function replayUrl(matchId: string): string { + return `${REPLAY_BASE}/${matchId}.json.gz`; +} + +/** + * Fetch and parse a replay from a URL, transparently gunzipping .gz responses. + * Throws `Error("HTTP ")` on a non-OK response so callers can branch on 404. + */ +export async function fetchReplayFromUrl(url: string): Promise { + const resp = await fetch(url); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + if (url.endsWith('.gz') && resp.body && typeof DecompressionStream !== 'undefined') { + const stream = resp.body.pipeThrough(new DecompressionStream('gzip')); + return JSON.parse(await new Response(stream).text()) as Replay; + } + return (await resp.json()) as Replay; +} + +/** Fetch a replay by match ID from the bundled Pages assets. */ +export function fetchReplay(matchId: string): Promise { + return fetchReplayFromUrl(replayUrl(matchId)); +} diff --git a/web/src/og-tags.ts b/web/src/og-tags.ts index cdb79db..b2240fa 100644 --- a/web/src/og-tags.ts +++ b/web/src/og-tags.ts @@ -68,7 +68,7 @@ export function getBotProfileOGTags(bot: { win_rate: number; evolved?: boolean; }): OGTags { - const cardUrl = `/r2/cards/${bot.id}.png`; + const cardUrl = `/data/cards/${bot.id}.png`; return { title: `${bot.name} - Bot Profile`, @@ -89,7 +89,7 @@ export function getReplayOGTags(match: { }): OGTags { const winner = match.participants.find(p => p.won); const winnerName = winner ? winner.name : 'Draw'; - const thumbnailUrl = `/r2/thumbnails/${match.id}.png`; + const thumbnailUrl = `/data/thumbnails/${match.id}.png`; return { title: `Match: ${match.participants.map(p => p.name).join(' vs ')}`, diff --git a/web/src/pages/bot-profile.ts b/web/src/pages/bot-profile.ts index 73792e9..2296f91 100644 --- a/web/src/pages/bot-profile.ts +++ b/web/src/pages/bot-profile.ts @@ -204,7 +204,7 @@ function renderMatchItem(match: BotProfile['recent_matches'][number]): string { ${opponent ? escapeHtml(opponent.name) : 'Unknown'} ${match.participants.map(p => p.score).join(' - ')} ${enrichedBadge} - Watch + Watch `; } @@ -232,7 +232,7 @@ function renderRivalsSection(rivalries: RivalryEntry[], botId: string): string { ${botWins}-${opponentWins}${r.record.draws > 0 ? `-${r.record.draws}` : ''} ${winPct}% win rate - ${r.closest_match ? `Watch closest match` : ''} + ${r.closest_match ? `Watch closest match` : ''} `; }).join(''); diff --git a/web/src/pages/embed.ts b/web/src/pages/embed.ts index 6a59d01..0283508 100644 --- a/web/src/pages/embed.ts +++ b/web/src/pages/embed.ts @@ -1,5 +1,6 @@ // Embeddable replay viewer - minimal chrome for iframe embedding // §13.4: /embed/{id} - auto-play, ~50KB, Open Graph tags +import { fetchReplayFromUrl } from '../lib/replay-data'; const loadReplayViewer = () => import('../replay-viewer'); @@ -37,7 +38,7 @@ export function renderEmbedPage(params: Record): void { } loadReplayViewer().then(({ ReplayViewer }) => { - const replayUrl = params.id ? `/r2/replays/${params.id}.json.gz` : undefined; + const replayUrl = params.id ? `/data/replays/${params.id}.json.gz` : undefined; if (!replayUrl) { const noReplay = document.getElementById('no-replay'); if (noReplay) noReplay.textContent = 'No replay specified'; @@ -69,12 +70,8 @@ export function renderEmbedPage(params: Record): void { } }; - // Load replay - fetch(replayUrl) - .then(resp => { - if (!resp.ok) throw new Error(`Failed to load replay: ${resp.status}`); - return resp.json(); - }) + // Load replay (gzipped static asset → fetchReplayFromUrl gunzips it) + fetchReplayFromUrl(replayUrl) .then((replay: any) => { viewer.loadReplay(replay); viewer.setTurn(startTurn); diff --git a/web/src/pages/feedback.ts b/web/src/pages/feedback.ts index 41ddeaa..97f496c 100644 --- a/web/src/pages/feedback.ts +++ b/web/src/pages/feedback.ts @@ -401,7 +401,7 @@ function initFeedback(): void { // ─── Utilities ──────────────────────────────────────────────────────────────── function replayUrlForMatch(m: MatchSummary): string { - return `/r2/replays/${m.id}.json.gz`; + return `/data/replays/${m.id}.json.gz`; } function formatDate(s: string | null): string { diff --git a/web/src/pages/home.ts b/web/src/pages/home.ts index a09cd40..fc10362 100644 --- a/web/src/pages/home.ts +++ b/web/src/pages/home.ts @@ -88,7 +88,7 @@ function renderPlaylistCards(playlists: any[]): string {
${pl.thumbnail_match_id - ? `${esc(pl.title)}` + ? `${esc(pl.title)}` : '
'}
@@ -178,7 +178,7 @@ export async function renderHomePage(): Promise { ? `${featuredReplay!.participants.map((p) => `${esc(p.name)}`).join(' vs ')}${featuredReplay!.winner_id ? ` — Winner: ${esc(featuredReplay!.participants.find((p) => p.bot_id === featuredReplay!.winner_id)?.name || 'Unknown')}` : ''}` : 'Demo Replay — Watch a sample battle'; const replayLink = hasLiveReplay - ? `#/watch/replay?url=/r2/replays/${featuredReplay!.id}.json.gz` + ? `#/watch/replay?url=/data/replays/${featuredReplay!.id}.json.gz` : '#/watch/replays'; // Build lazy-loaded content for below-the-fold sections diff --git a/web/src/pages/matches.ts b/web/src/pages/matches.ts index 9d5649b..f924a82 100644 --- a/web/src/pages/matches.ts +++ b/web/src/pages/matches.ts @@ -319,7 +319,7 @@ function renderMatchCard(match: MatchSummary): string { ${match.end_reason ?? '-'} ${match.map_id ? `Map: ${escapeHtml(match.map_id)}` : ''}
-
Watch Replay + Watch Replay `; diff --git a/web/src/pages/playlists.ts b/web/src/pages/playlists.ts index a547db4..4c4a3bd 100644 --- a/web/src/pages/playlists.ts +++ b/web/src/pages/playlists.ts @@ -532,7 +532,7 @@ function addMatchShowMore(container: HTMLElement, remaining: PlaylistMatch[]): v } function watchMatch(matchId: string): void { - window.location.hash = `/watch/replay?url=/r2/replays/${matchId}.json.gz`; + window.location.hash = `/watch/replay?url=/data/replays/${matchId}.json.gz`; } function copyEmbedCode(matchId: string): void { diff --git a/web/src/pages/replay.ts b/web/src/pages/replay.ts index 64758ea..d89abaa 100644 --- a/web/src/pages/replay.ts +++ b/web/src/pages/replay.ts @@ -28,6 +28,7 @@ import { import { THEATER_STYLES, TheaterMode } from '../components/theater'; import { setActiveReplay } from '../components/pip-registry'; import { getPipMatchId, restorePip } from '../components/pip'; +import { fetchReplayFromUrl } from '../lib/replay-data'; const loadReplayViewer = () => import('../replay-viewer'); @@ -46,7 +47,7 @@ export function renderReplayPage(params: Record): void { loadReplayViewer().then(({ ReplayViewer }) => { // If params.url is not set but params.id is, construct the URL from the match ID - const replayUrl = params.url || (params.id ? `/r2/replays/${params.id}.json.gz` : undefined); + const replayUrl = params.url || (params.id ? `/data/replays/${params.id}.json.gz` : undefined); initReplayViewerWithClass(ReplayViewer, replayUrl); }); } @@ -1397,18 +1398,11 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { const url = urlInput.value.trim(); if (!url) return; try { - const response = await fetch(url); - if (!response.ok) { - if (response.status === 404) { - throw new Error('HTTP_404'); - } - throw new Error(`HTTP ${response.status}`); - } - const replay = await response.json() as Replay; + const replay = await fetchReplayFromUrl(url); loadReplay(replay); } catch (err) { const errMsg = String(err); - if (errMsg.includes('HTTP_404')) { + if (errMsg.includes('HTTP 404')) { showLoadError('This replay is not available yet — it may not have been uploaded.', url); } else if (errMsg.includes('HTTP')) { showLoadError(`Could not load this replay: ${errMsg}`, url);