From c5a83cbe3225fea83696a9a756dedf82db042d2c Mon Sep 17 00:00:00 2001 From: jedarden Date: Tue, 21 Apr 2026 12:41:33 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20code=20splitting=20per=20=C2=A716.?= =?UTF-8?q?7=20=E2=80=94=20reduce=20app=20entry=20chunk=2084%=20(10KB=20?= =?UTF-8?q?=E2=86=92=201.6KB=20gz)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract all inline page renderers from app.ts into lazy-loaded modules and remove 500+ lines of duplicated replay viewer code. Every route now uses dynamic import() so page code loads on-demand. Changes: - Remove duplicated replay viewer (renderReplayPage, initReplayViewer) from app.ts — now uses lazy import from pages/replay.ts - Extract Watch Hub, Compete Hub, Season Detail, Docs, and 404 pages into their own modules (pages/watch-hub.ts, compete-hub.ts, season-detail.ts, docs.ts, not-found.ts) - app.ts is now a pure routing module (~120 lines) with only lazy loaders - Update vite.config.ts manualChunks: add replay-page, home, leaderboard chunks; add node_modules guard to prevent vendor code in page chunks - All §16.7 budget targets pass: app.js 1.6KB (target 30KB), replay 13KB (target 80KB), sandbox 8.4KB (target 20KB), agentation separate Co-Authored-By: Claude Opus 4.7 --- web/src/app.ts | 1017 +------------------------------- web/src/pages/compete-hub.ts | 99 ++++ web/src/pages/docs.ts | 99 ++++ web/src/pages/not-found.ts | 20 + web/src/pages/replay.ts | 512 ++++++++++++++++ web/src/pages/season-detail.ts | 146 +++++ web/src/pages/watch-hub.ts | 92 +++ web/vite.config.ts | 26 +- 8 files changed, 1017 insertions(+), 994 deletions(-) create mode 100644 web/src/pages/compete-hub.ts create mode 100644 web/src/pages/docs.ts create mode 100644 web/src/pages/not-found.ts create mode 100644 web/src/pages/replay.ts create mode 100644 web/src/pages/season-detail.ts create mode 100644 web/src/pages/watch-hub.ts diff --git a/web/src/app.ts b/web/src/app.ts index 6de114a..9f4786d 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -1,8 +1,9 @@ // Main SPA entry point with routing -// Code splitting: pages are loaded on-demand to keep initial bundle small +// Code splitting: all pages are loaded on-demand via dynamic import() to keep +// the initial bundle small. The app entry chunk contains only the router, +// navigation, and lazy-loading wrappers — no page renderers. import { router } from './router'; import type { RouteHandler } from './router'; -import type { Replay, GameEvent } from './types'; // ─── Lazy loaders for code splitting ───────────────────────────────────────────── // Each loader creates its own chunk, loaded only when the route is visited @@ -15,11 +16,14 @@ const loadLeaderboardPage = () => import('./pages/leaderboard').then(m => m.rend const loadMatchesPage = () => import('./pages/matches').then(m => m.renderMatchesPage); const loadSeriesPage = () => import('./pages/series').then(m => m.renderSeriesPage); const loadPredictionsPage = () => import('./pages/predictions').then(m => m.renderPredictionsPage); -const loadReplayViewer = () => import('./replay-viewer'); +const loadReplayPage = () => import('./pages/replay').then(m => m.renderReplayPage); +const loadWatchHubPage = () => import('./pages/watch-hub').then(m => m.renderWatchHubPage); // Compete section - sandbox, register, docs const loadSandboxPage = () => import('./pages/sandbox').then(m => m.renderSandboxPage); const loadRegisterPage = () => import('./pages/register').then(m => m.renderRegisterPage); +const loadCompeteHubPage = () => import('./pages/compete-hub').then(m => m.renderCompeteHubPage); +const loadDocsPage = () => import('./pages/docs').then(m => m.renderDocsPage); // Bot-related pages const loadBotProfilePage = () => import('./pages/bot-profile').then(m => m.renderBotProfilePage); @@ -28,11 +32,22 @@ const loadEvolutionPage = () => import('./pages/evolution').then(m => m.renderEv // Blog & seasons const loadBlogPages = () => import('./pages/blog').then(m => ({ renderBlogPage: m.renderBlogPage, renderBlogPostPage: m.renderBlogPostPage })); const loadSeasonsPage = () => import('./pages/seasons').then(m => m.renderSeasonsPage); +const loadSeasonDetailPage = () => import('./pages/season-detail').then(m => m.renderSeasonDetailPage); // Feedback & docs (separate chunk - includes replay viewer for feedback page) -const loadFeedbackPage = () => import('./pages/feedback').then(m => m.renderFeedbackPage); +// Feedback page lazy-loads with agentation (loaded on /#/feedback or explicit enable) +// Agentation is NOT imported here — only loaded when feedback page is visited +const loadFeedbackPage = () => import('./pages/feedback').then(async m => { + const { initAgentation } = await import('./agentation-overlay'); + initAgentation(); + return m.renderFeedbackPage; +}); +// Docs API page (separate chunk from compete docs) const loadDocsApiPage = () => import('./pages/docs-api').then(m => m.renderDocsApiPage); +// 404 +const loadNotFoundPage = () => import('./pages/not-found').then(m => m.renderNotFoundPage); + // ─── Helper: wrap async page loader in sync RouteHandler ──────────────────────── function lazyRoute(loader: () => Promise<(params: Record) => void>): RouteHandler { return (params: Record) => { @@ -51,987 +66,19 @@ function redirect(to: string): RouteHandler { }; } -// ─── In-page route handlers (no lazy load needed) ──────────────────────────────── - -// Watch hub page - spectator hub with replays, playlists, predictions -function renderWatchHubPage(): void { - const app = document.getElementById('app'); - if (!app) return; - - app.innerHTML = ` - - - - `; - - loadFeaturedPlaylists(); -} - -async function loadFeaturedPlaylists(): Promise { - const container = document.getElementById('featured-playlists'); - if (!container) return; - - try { - const response = fetch('/data/playlists/index.json'); - const data = await (await response).json(); - - if (data.playlists.length === 0) { - container.innerHTML = '

No playlists available yet.

'; - return; - } - - const featured = data.playlists.slice(0, 4); - container.innerHTML = featured.map((p: any) => ` - -

${escapeHtml(p.title)}

-

${p.match_count} matches

-
- `).join(''); - } catch { - container.innerHTML = '

Failed to load playlists.

'; - } -} - -// Compete hub page - participant hub with sandbox, register, docs -function renderCompeteHubPage(): void { - const app = document.getElementById('app'); - if (!app) return; - - app.innerHTML = ` -
-

Compete

-

Build your bot and climb the ranks

- -
-

Getting Started

-

AI Code Battle is a competitive programming platform where you write HTTP bots that control units on a grid world.

-
- - - -
-

How Competition Works

-
-
- 1 -

Build a Bot

-

Write an HTTP server that receives game state and returns move commands

-
-
- 2 -

Register

-

Submit your bot's endpoint URL and API key to start competing

-
-
- 3 -

Climb the Ranks

-

Your bot plays matches automatically and earns rating through Glicko-2

-
-
-
-
- - - `; -} - -// Season detail page - standalone page for viewing a specific season -function renderSeasonDetailPage(params: Record): void { - const seasonId = params.id; - if (!seasonId) { - router.navigate('/seasons'); - return; - } - - const app = document.getElementById('app'); - if (!app) return; - - app.innerHTML = ` -
- -
Loading season...
-
- - - `; - - loadSeasonDetail(seasonId); -} - -async function loadSeasonDetail(seasonId: string): Promise { - const breadcrumb = document.getElementById('season-breadcrumb'); - const content = document.getElementById('season-content'); - - if (!content) return; - - try { - const response = await fetch(`/data/seasons/${seasonId}.json`); - if (!response.ok) throw new Error('Season not found'); - const season = await response.json(); - - if (breadcrumb) { - breadcrumb.textContent = season.name; - } - - content.innerHTML = ` -
-
-

${escapeHtml(season.name)}

-

${escapeHtml(season.theme)}

-
-
- ${season.status} -
Started: ${new Date(season.starts_at).toLocaleDateString()}
- ${season.ends_at ? `
Ended: ${new Date(season.ends_at).toLocaleDateString()}
` : ''} -
-
- - ${season.champion_name ? ` -
-
👑
-
Champion
-
${escapeHtml(season.champion_name)}
-
- ` : ''} - - ${season.final_snapshot && season.final_snapshot.length > 0 ? ` -

Final Leaderboard

- - - - - - - - - - - - ${season.final_snapshot.map((entry: any) => ` - - - - - - - - `).join('')} - -
RankBotRatingWinsLosses
#${entry.rank}${escapeHtml(entry.bot_name)}${Math.round(entry.rating)}${entry.wins}${entry.losses}
- ` : ''} - -
-

Rules Version: ${season.rules_version}

-
    -
  • Standard 60×60 toroidal grid
  • -
  • 500 turn limit
  • -
  • Glicko-2 rating system
  • -
  • Best-of-1 matches
  • -
-
- `; - } catch (err) { - console.error('Failed to load season:', err); - content.innerHTML = ` -
-

Failed to load season: ${seasonId}

-

The season may not exist yet.

- Back to Seasons -
- `; - } -} - -// Replay viewer page - lazy loads the ReplayViewer class -function renderReplayPage(params: Record): void { - const app = document.getElementById('app'); - if (!app) return; - - // Show loading state while ReplayViewer loads - app.innerHTML = ` -
-

Replay Viewer

-
- Loading replay viewer... -
-
- `; - - // Lazy load ReplayViewer and initialize - loadReplayViewer().then(({ ReplayViewer }) => { - initReplayViewerWithClass(ReplayViewer, params.url); - }); -} - -function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string): void { - const app = document.getElementById('app'); - if (!app) return; - - app.innerHTML = ` -
-

Replay Viewer

- -
-
-
- -
Load a replay file to view
-
- -
- -
-
-

Load Replay

-
-
- - -
-
- - -
-
-
- -
-

Playback

-
- - - - -
-
- - -
-
- - -
-
- -
-

View Options

-
- - - - -
-
- -
-

Accessibility

-
- - - - -
-
- -
-

Match Info

-
-
Match ID
-
-
-
Winner
-
-
-
Turns
-
-
-
Reason
-
-
-
-
- -
-

Events This Turn

-
-
No events
-
-
- -
- Space Play/Pause - Step - HomeEnd First/Last -
-
-
-
- - - `; - - initReplayViewer(ReplayViewerClass, initialUrl); -} - -function initReplayViewer(ReplayViewerClass: any, 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; - const winProbSection = document.getElementById('win-prob-section') as HTMLDivElement; - const winProbContainer = document.getElementById('win-prob-container') as HTMLDivElement; - const prevCriticalBtn = document.getElementById('prev-critical-btn') as HTMLButtonElement; - const nextCriticalBtn = document.getElementById('next-critical-btn') as HTMLButtonElement; - const criticalMomentInfo = document.getElementById('critical-moment-info') as HTMLSpanElement; - const wpP0Label = document.getElementById('wp-p0-label') as HTMLSpanElement; - const wpP1Label = document.getElementById('wp-p1-label') as HTMLSpanElement; - - let viewer = new ReplayViewerClass(canvas, { cellSize: 10 }); - let criticalMoments: Array<{turn: number; delta: number; description: string}> = []; - - 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 = '
No events
'; - return; - } - eventLogDiv.innerHTML = events.map((e: GameEvent) => { - const type = e.type.replace(/_/g, ' '); - return `
${type}
`; - }).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 = ''; - 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(); - initWinProb(replay); - } - - function initWinProb(replay: Replay): void { - if (!replay.win_prob || replay.win_prob.length === 0) { - winProbSection.style.display = 'none'; - return; - } - - const points = replay.win_prob.map((pair: any, t: number) => ({ - turn: t, - p0WinProb: pair[0] ?? 0.5, - p1WinProb: pair[1] ?? 0.5, - drawProb: Math.max(0, 1 - (pair[0] ?? 0.5) - (pair[1] ?? 0.5)), - })); - - criticalMoments = replay.critical_moments ?? []; - - viewer.setWinProbabilityData(points); - viewer.setCriticalMoments(criticalMoments); - - winProbSection.style.display = 'block'; - - if (replay.players.length >= 1) wpP0Label.textContent = `— ${replay.players[0].name}`; - if (replay.players.length >= 2) wpP1Label.textContent = `-- ${replay.players[1].name}`; - - winProbContainer.innerHTML = ''; - viewer.createWinProbSparkline(winProbContainer, 800, 70, (turn: number) => { - viewer.setTurn(turn); - updateUI(); - updateEventLog(); - }); - - updateCriticalMomentNav(); - } - - function updateCriticalMomentNav(): void { - const hasMoments = criticalMoments.length > 0; - prevCriticalBtn.disabled = !hasMoments; - nextCriticalBtn.disabled = !hasMoments; - - if (hasMoments) { - const currentTurn = viewer.getTurn(); - const atMoment = criticalMoments.find((m: any) => m.turn === currentTurn); - if (atMoment) { - criticalMomentInfo.textContent = atMoment.description; - } else { - criticalMomentInfo.textContent = `${criticalMoments.length} critical moment${criticalMoments.length !== 1 ? 's' : ''}`; - } - } else { - criticalMomentInfo.textContent = '—'; - } - } - - prevCriticalBtn.addEventListener('click', () => { - const currentTurn = viewer.getTurn(); - const prev = [...criticalMoments].reverse().find((m: any) => m.turn < currentTurn); - if (prev) { - viewer.setTurn(prev.turn); - updateUI(); - updateEventLog(); - criticalMomentInfo.textContent = prev.description; - } - }); - - nextCriticalBtn.addEventListener('click', () => { - const currentTurn = viewer.getTurn(); - const next = criticalMoments.find((m: any) => m.turn > currentTurn); - if (next) { - viewer.setTurn(next.turn); - updateUI(); - updateEventLog(); - criticalMomentInfo.textContent = next.description; - } - }); - - 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) { - const prevTurn = viewer.getTurn(); - viewer.destroy(); - viewer = new ReplayViewerClass(canvas, { cellSize: size }); - loadReplay(replay); - viewer.setTurn(prevTurn); - updateUI(); - } - }); - - // Accessibility toggle handlers - const colorBlindToggle = document.getElementById('color-blind-toggle') as HTMLInputElement; - const shapesToggle = document.getElementById('shapes-toggle') as HTMLInputElement; - const highContrastToggle = document.getElementById('high-contrast-toggle') as HTMLInputElement; - const reducedMotionToggle = document.getElementById('reduced-motion-toggle') as HTMLInputElement; - - function updateAccessibility(): void { - viewer.setAccessibility({ - colorBlindSafe: colorBlindToggle.checked, - showShapes: shapesToggle.checked, - highContrast: highContrastToggle.checked, - reducedMotion: reducedMotionToggle.checked, - }); - } - - colorBlindToggle.addEventListener('change', updateAccessibility); - shapesToggle.addEventListener('change', updateAccessibility); - highContrastToggle.addEventListener('change', updateAccessibility); - reducedMotionToggle.addEventListener('change', updateAccessibility); - - // Initialize accessibility from system preferences - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { - reducedMotionToggle.checked = true; - updateAccessibility(); - } - - viewer.onTurnChange = () => { - updateUI(); - updateEventLog(); - if (criticalMoments.length > 0) updateCriticalMomentNav(); - }; - viewer.onPlayStateChange = (playing: boolean) => { 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 = ` -
-

Getting Started

- -
-
-

Overview

-

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.

-
- -
-

Game Basics

-
    -
  • Grid: The game is played on a toroidal (wrapping) grid
  • -
  • Units: Each player controls bots that move one tile per turn
  • -
  • Resources: Collect energy from nodes to spawn new bots
  • -
  • Objectives: Capture enemy cores, eliminate opponents, or dominate through numbers
  • -
-
- -
-

HTTP Protocol

-

Your bot must expose an HTTPS endpoint that accepts POST requests with JSON game state and returns JSON move commands.

- -

Request Format

-
{
-  "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
-}
- -

Response Format

-
{
-  "moves": [
-    { "bot_id": "bot-1", "direction": "N" }
-  ]
-}
- -

Valid Directions

-

N (North), E (East), S (South), W (West)

-
- -
-

Authentication

-

Requests from the game engine are signed with HMAC-SHA256. The signature is sent in the X-Signature header.

-

Format: {match_id}.{turn}.{timestamp}.{sha256(body)}

-

Your bot should verify signatures using your API key to ensure requests are authentic.

-
- -
-

Requirements

-
    -
  • HTTPS endpoint accessible from the internet
  • -
  • Response time under 3 seconds per turn
  • -
  • Handle concurrent requests (multiple matches)
  • -
  • Return valid JSON for every request
  • -
-
- -
-

Example Bot

-

See the example bots in various languages for reference implementations.

-
- -
-

Data & API

-

All match data (leaderboards, replays, bot profiles) is exposed as static JSON files served from CDN.

-

View API Reference

-
-
-
- - - `; -} - -// 404 page -function renderNotFoundPage(): void { - const app = document.getElementById('app'); - if (!app) return; - - app.innerHTML = ` -
-

404

-

Page not found

- Go Home -
- - - `; -} - -// ─── Utilities ─────────────────────────────────────────────────────────────────── - -function escapeHtml(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - // ─── Navigation & UI ─────────────────────────────────────────────────────────────── -// Update active nav link on route change function updateActiveNavLink(): void { const currentPath = router.getCurrentPath(); - // Clear all active states document.querySelectorAll('.nav-link').forEach(link => { link.classList.remove('active'); }); - // Set active state for matching links document.querySelectorAll('.nav-link').forEach(link => { const href = link.getAttribute('href'); if (href) { - const linkPath = href.slice(2); // Remove '#/' - // Check for exact match or prefix match for hub pages + const linkPath = href.slice(2); if (currentPath === linkPath || (linkPath !== '' && currentPath.startsWith(linkPath)) || (linkPath === '/watch' && currentPath.startsWith('/watch')) || @@ -1042,7 +89,6 @@ function updateActiveNavLink(): void { }); } -// Mobile menu toggle function initMobileMenu(): void { const toggle = document.getElementById('mobile-menu-toggle'); const menu = document.getElementById('mobile-menu'); @@ -1053,14 +99,12 @@ function initMobileMenu(): void { menu.classList.toggle('open'); }); - // Close menu when clicking outside document.addEventListener('click', (e) => { if (!menu.contains(e.target as Node) && !toggle.contains(e.target as Node)) { menu.classList.remove('open'); } }); - // Close menu on route change const originalNavigate = router.navigate.bind(router); router.navigate = (path: string) => { originalNavigate(path); @@ -1068,10 +112,8 @@ function initMobileMenu(): void { }; } -// Initialize mobile menu on DOM ready initMobileMenu(); -// Override router navigation to update nav links const originalNavigate = router.navigate.bind(router); router.navigate = (path: string) => { originalNavigate(path); @@ -1083,22 +125,22 @@ router.navigate = (path: string) => { router // Main routes .on('/', lazyRoute(loadHomePage)) - .on('/watch', renderWatchHubPage) + .on('/watch', lazyRoute(loadWatchHubPage)) .on('/watch/replays', lazyRoute(loadMatchesPage)) - .on('/watch/replay/:id', renderReplayPage) + .on('/watch/replay/:id', lazyRoute(loadReplayPage)) .on('/watch/series/:id', lazyRoute(loadSeriesPage)) .on('/watch/predictions', lazyRoute(loadPredictionsPage)) .on('/watch/series', lazyRoute(loadSeriesPage)) - .on('/compete', renderCompeteHubPage) + .on('/compete', lazyRoute(loadCompeteHubPage)) .on('/compete/sandbox', lazyRoute(loadSandboxPage)) .on('/compete/register', lazyRoute(loadRegisterPage)) .on('/compete/bot/:id', lazyRoute(loadBotProfilePage)) - .on('/compete/docs', renderDocsPage) + .on('/compete/docs', lazyRoute(loadDocsPage)) .on('/leaderboard', lazyRoute(loadLeaderboardPage)) .on('/evolution', lazyRoute(loadEvolutionPage)) .on('/blog', lazyRoute(async () => (await loadBlogPages()).renderBlogPage)) .on('/blog/:slug', lazyRoute(async () => (await loadBlogPages()).renderBlogPostPage)) - .on('/season/:id', renderSeasonDetailPage) + .on('/season/:id', lazyRoute(loadSeasonDetailPage)) .on('/seasons', lazyRoute(loadSeasonsPage)) .on('/bot/:id', lazyRoute(loadBotProfilePage)) // Backwards compatibility redirects @@ -1114,19 +156,18 @@ router .on('/docs/api', redirect('/compete/docs')) .on('/clip-maker', redirect('/watch/replays')) .on('/rivalries', redirect('/watch/replays')) - .on('/feedback', redirect('/compete/docs')) - .notFound(renderNotFoundPage); + .on('/feedback', lazyRoute(loadFeedbackPage)) + .on('/compete/feedback', lazyRoute(loadFeedbackPage)) + .on('/compete/docs/api', lazyRoute(loadDocsApiPage)) + .notFound(lazyRoute(loadNotFoundPage)); // ─── Initialization ──────────────────────────────────────────────────────────────── -// Start the router - Agentation is no longer auto-loaded on every page document.addEventListener('DOMContentLoaded', () => { updateActiveNavLink(); router.start(); - // Agentation removed from auto-init - now loads only when needed }); -// Update nav on initial load window.addEventListener('load', () => { updateActiveNavLink(); }); diff --git a/web/src/pages/compete-hub.ts b/web/src/pages/compete-hub.ts new file mode 100644 index 0000000..514053e --- /dev/null +++ b/web/src/pages/compete-hub.ts @@ -0,0 +1,99 @@ +// Compete hub page - participant hub with sandbox, register, docs + +export function renderCompeteHubPage(): void { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+

Compete

+

Build your bot and climb the ranks

+ +
+

Getting Started

+

AI Code Battle is a competitive programming platform where you write HTTP bots that control units on a grid world.

+
+ + + +
+

How Competition Works

+
+
+ 1 +

Build a Bot

+

Write an HTTP server that receives game state and returns move commands

+
+
+ 2 +

Register

+

Submit your bot's endpoint URL and API key to start competing

+
+
+ 3 +

Climb the Ranks

+

Your bot plays matches automatically and earns rating through Glicko-2

+
+
+
+
+ + + `; +} diff --git a/web/src/pages/docs.ts b/web/src/pages/docs.ts new file mode 100644 index 0000000..21a9010 --- /dev/null +++ b/web/src/pages/docs.ts @@ -0,0 +1,99 @@ +// Docs/Getting Started page + +export function renderDocsPage(): void { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+

Getting Started

+ +
+
+

Overview

+

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.

+
+ +
+

Game Basics

+
    +
  • Grid: The game is played on a toroidal (wrapping) grid
  • +
  • Units: Each player controls bots that move one tile per turn
  • +
  • Resources: Collect energy from nodes to spawn new bots
  • +
  • Objectives: Capture enemy cores, eliminate opponents, or dominate through numbers
  • +
+
+ +
+

HTTP Protocol

+

Your bot must expose an HTTPS endpoint that accepts POST requests with JSON game state and returns JSON move commands.

+ +

Request Format

+
{
+  "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
+}
+ +

Response Format

+
{
+  "moves": [
+    { "bot_id": "bot-1", "direction": "N" }
+  ]
+}
+ +

Valid Directions

+

N (North), E (East), S (South), W (West)

+
+ +
+

Authentication

+

Requests from the game engine are signed with HMAC-SHA256. The signature is sent in the X-Signature header.

+

Format: {match_id}.{turn}.{timestamp}.{sha256(body)}

+

Your bot should verify signatures using your API key to ensure requests are authentic.

+
+ +
+

Requirements

+
    +
  • HTTPS endpoint accessible from the internet
  • +
  • Response time under 3 seconds per turn
  • +
  • Handle concurrent requests (multiple matches)
  • +
  • Return valid JSON for every request
  • +
+
+ +
+

Example Bot

+

See the example bots in various languages for reference implementations.

+
+ +
+

Data & API

+

All match data (leaderboards, replays, bot profiles) is exposed as static JSON files served from CDN.

+

View API Reference

+
+
+
+ + + `; +} diff --git a/web/src/pages/not-found.ts b/web/src/pages/not-found.ts new file mode 100644 index 0000000..a4b8318 --- /dev/null +++ b/web/src/pages/not-found.ts @@ -0,0 +1,20 @@ +// 404 page + +export function renderNotFoundPage(): void { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+

404

+

Page not found

+ Go Home +
+ + + `; +} diff --git a/web/src/pages/replay.ts b/web/src/pages/replay.ts new file mode 100644 index 0000000..791e4da --- /dev/null +++ b/web/src/pages/replay.ts @@ -0,0 +1,512 @@ +// Standalone replay viewer page - lazy loaded from app.ts +import type { Replay, GameEvent } from '../types'; + +const loadReplayViewer = () => import('../replay-viewer'); + +export function renderReplayPage(params: Record): void { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+

Replay Viewer

+
+ Loading replay viewer... +
+
+ `; + + loadReplayViewer().then(({ ReplayViewer }) => { + initReplayViewerWithClass(ReplayViewer, params.url); + }); +} + +function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string): void { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+

Replay Viewer

+ +
+
+
+ +
Load a replay file to view
+
+ +
+ +
+
+

Load Replay

+
+
+ + +
+
+ + +
+
+
+ +
+

Playback

+
+ + + + +
+
+ + +
+
+ + +
+
+ +
+

View Options

+
+ + + + +
+
+ +
+

Accessibility

+
+ + + + +
+
+ +
+

Match Info

+
+
Match ID
+
-
+
Winner
+
-
+
Turns
+
-
+
Reason
+
-
+
+
+ +
+

Events This Turn

+
+
No events
+
+
+ +
+ Space Play/Pause + Step + HomeEnd First/Last +
+
+
+
+ + + `; + + initReplayViewer(ReplayViewerClass, initialUrl); +} + +function initReplayViewer(ReplayViewerClass: any, 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; + const winProbSection = document.getElementById('win-prob-section') as HTMLDivElement; + const winProbContainer = document.getElementById('win-prob-container') as HTMLDivElement; + const prevCriticalBtn = document.getElementById('prev-critical-btn') as HTMLButtonElement; + const nextCriticalBtn = document.getElementById('next-critical-btn') as HTMLButtonElement; + const criticalMomentInfo = document.getElementById('critical-moment-info') as HTMLSpanElement; + const wpP0Label = document.getElementById('wp-p0-label') as HTMLSpanElement; + const wpP1Label = document.getElementById('wp-p1-label') as HTMLSpanElement; + + let viewer = new ReplayViewerClass(canvas, { cellSize: 10 }); + let criticalMoments: Array<{turn: number; delta: number; description: string}> = []; + + 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 = '
No events
'; + return; + } + eventLogDiv.innerHTML = events.map((e: GameEvent) => { + const type = e.type.replace(/_/g, ' '); + return `
${type}
`; + }).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 = ''; + 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(); + initWinProb(replay); + } + + function initWinProb(replay: Replay): void { + if (!replay.win_prob || replay.win_prob.length === 0) { + winProbSection.style.display = 'none'; + return; + } + + const points = replay.win_prob.map((pair: any, t: number) => ({ + turn: t, + p0WinProb: pair[0] ?? 0.5, + p1WinProb: pair[1] ?? 0.5, + drawProb: Math.max(0, 1 - (pair[0] ?? 0.5) - (pair[1] ?? 0.5)), + })); + + criticalMoments = replay.critical_moments ?? []; + + viewer.setWinProbabilityData(points); + viewer.setCriticalMoments(criticalMoments); + + winProbSection.style.display = 'block'; + + if (replay.players.length >= 1) wpP0Label.textContent = `— ${replay.players[0].name}`; + if (replay.players.length >= 2) wpP1Label.textContent = `-- ${replay.players[1].name}`; + + winProbContainer.innerHTML = ''; + viewer.createWinProbSparkline(winProbContainer, 800, 70, (turn: number) => { + viewer.setTurn(turn); + updateUI(); + updateEventLog(); + }); + + updateCriticalMomentNav(); + } + + function updateCriticalMomentNav(): void { + const hasMoments = criticalMoments.length > 0; + prevCriticalBtn.disabled = !hasMoments; + nextCriticalBtn.disabled = !hasMoments; + + if (hasMoments) { + const currentTurn = viewer.getTurn(); + const atMoment = criticalMoments.find((m: any) => m.turn === currentTurn); + if (atMoment) { + criticalMomentInfo.textContent = atMoment.description; + } else { + criticalMomentInfo.textContent = `${criticalMoments.length} critical moment${criticalMoments.length !== 1 ? 's' : ''}`; + } + } else { + criticalMomentInfo.textContent = '—'; + } + } + + prevCriticalBtn.addEventListener('click', () => { + const currentTurn = viewer.getTurn(); + const prev = [...criticalMoments].reverse().find((m: any) => m.turn < currentTurn); + if (prev) { + viewer.setTurn(prev.turn); + updateUI(); + updateEventLog(); + criticalMomentInfo.textContent = prev.description; + } + }); + + nextCriticalBtn.addEventListener('click', () => { + const currentTurn = viewer.getTurn(); + const next = criticalMoments.find((m: any) => m.turn > currentTurn); + if (next) { + viewer.setTurn(next.turn); + updateUI(); + updateEventLog(); + criticalMomentInfo.textContent = next.description; + } + }); + + 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) { + const prevTurn = viewer.getTurn(); + viewer.destroy(); + viewer = new ReplayViewerClass(canvas, { cellSize: size }); + loadReplay(replay); + viewer.setTurn(prevTurn); + updateUI(); + } + }); + + // Accessibility toggle handlers + const colorBlindToggle = document.getElementById('color-blind-toggle') as HTMLInputElement; + const shapesToggle = document.getElementById('shapes-toggle') as HTMLInputElement; + const highContrastToggle = document.getElementById('high-contrast-toggle') as HTMLInputElement; + const reducedMotionToggle = document.getElementById('reduced-motion-toggle') as HTMLInputElement; + + function updateAccessibility(): void { + viewer.setAccessibility({ + colorBlindSafe: colorBlindToggle.checked, + showShapes: shapesToggle.checked, + highContrast: highContrastToggle.checked, + reducedMotion: reducedMotionToggle.checked, + }); + } + + colorBlindToggle.addEventListener('change', updateAccessibility); + shapesToggle.addEventListener('change', updateAccessibility); + highContrastToggle.addEventListener('change', updateAccessibility); + reducedMotionToggle.addEventListener('change', updateAccessibility); + + // Initialize accessibility from system preferences + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + reducedMotionToggle.checked = true; + updateAccessibility(); + } + + viewer.onTurnChange = () => { + updateUI(); + updateEventLog(); + if (criticalMoments.length > 0) updateCriticalMomentNav(); + }; + viewer.onPlayStateChange = (playing: boolean) => { 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(); + } +} diff --git a/web/src/pages/season-detail.ts b/web/src/pages/season-detail.ts new file mode 100644 index 0000000..d5ca951 --- /dev/null +++ b/web/src/pages/season-detail.ts @@ -0,0 +1,146 @@ +// Season detail page - standalone page for viewing a specific season +import { router } from '../router'; + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +export function renderSeasonDetailPage(params: Record): void { + const seasonId = params.id; + if (!seasonId) { + router.navigate('/seasons'); + return; + } + + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+ +
Loading season...
+
+ + + `; + + loadSeasonDetail(seasonId); +} + +async function loadSeasonDetail(seasonId: string): Promise { + const breadcrumb = document.getElementById('season-breadcrumb'); + const content = document.getElementById('season-content'); + + if (!content) return; + + try { + const response = await fetch(`/data/seasons/${seasonId}.json`); + if (!response.ok) throw new Error('Season not found'); + const season = await response.json(); + + if (breadcrumb) { + breadcrumb.textContent = season.name; + } + + content.innerHTML = ` +
+
+

${escapeHtml(season.name)}

+

${escapeHtml(season.theme)}

+
+
+ ${season.status} +
Started: ${new Date(season.starts_at).toLocaleDateString()}
+ ${season.ends_at ? `
Ended: ${new Date(season.ends_at).toLocaleDateString()}
` : ''} +
+
+ + ${season.champion_name ? ` +
+
👑
+
Champion
+
${escapeHtml(season.champion_name)}
+
+ ` : ''} + + ${season.final_snapshot && season.final_snapshot.length > 0 ? ` +

Final Leaderboard

+ + + + + + + + + + + + ${season.final_snapshot.map((entry: any) => ` + + + + + + + + `).join('')} + +
RankBotRatingWinsLosses
#${entry.rank}${escapeHtml(entry.bot_name)}${Math.round(entry.rating)}${entry.wins}${entry.losses}
+ ` : ''} + +
+

Rules Version: ${season.rules_version}

+
    +
  • Standard 60×60 toroidal grid
  • +
  • 500 turn limit
  • +
  • Glicko-2 rating system
  • +
  • Best-of-1 matches
  • +
+
+ `; + } catch (err) { + console.error('Failed to load season:', err); + content.innerHTML = ` +
+

Failed to load season: ${seasonId}

+

The season may not exist yet.

+ Back to Seasons +
+ `; + } +} diff --git a/web/src/pages/watch-hub.ts b/web/src/pages/watch-hub.ts new file mode 100644 index 0000000..b35a05c --- /dev/null +++ b/web/src/pages/watch-hub.ts @@ -0,0 +1,92 @@ +// Watch hub page - spectator hub with replays, playlists, predictions + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +export function renderWatchHubPage(): void { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` + + + + `; + + loadFeaturedPlaylists(); +} + +async function loadFeaturedPlaylists(): Promise { + const container = document.getElementById('featured-playlists'); + if (!container) return; + + try { + const response = fetch('/data/playlists/index.json'); + const data = await (await response).json(); + + if (data.playlists.length === 0) { + container.innerHTML = '

No playlists available yet.

'; + return; + } + + const featured = data.playlists.slice(0, 4); + container.innerHTML = featured.map((p: any) => ` + +

${escapeHtml(p.title)}

+

${p.match_count} matches

+
+ `).join(''); + } catch { + container.innerHTML = '

Failed to load playlists.

'; + } +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 58533b1..c7bf115 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -14,19 +14,25 @@ export default defineConfig({ }, output: { manualChunks(id) { - // Agentation: React + agentation library (lazy-loaded) + if (id.includes('node_modules')) return; + + // Agentation: React + agentation library (lazy-loaded only on /feedback) if (id.includes('react') || id.includes('agentation')) { return 'agentation'; } - // Replay viewer chunk (includes canvas rendering, charts) + // Replay viewer chunk (canvas renderer + win probability) if (id.includes('replay-viewer') || id.includes('win-probability')) { return 'replay-viewer'; } - // Sandbox chunk (includes engine orchestration) + // Replay page (uses replay-viewer, separate from the viewer chunk itself) + if (id.includes('pages/replay')) { + return 'replay-page'; + } + // Sandbox chunk (includes engine orchestration + WASM loader) if (id.includes('pages/sandbox')) { return 'sandbox'; } - // Evolution page (large, complex visualizations) + // Evolution page (live polling, SVG lineage tree, island grid) if (id.includes('pages/evolution')) { return 'evolution'; } @@ -34,7 +40,7 @@ export default defineConfig({ if (id.includes('pages/blog')) { return 'blog'; } - // Clip maker (video processing) + // Clip maker (video/GIF export) if (id.includes('pages/clip-maker')) { return 'clip-maker'; } @@ -42,10 +48,18 @@ export default defineConfig({ if (id.includes('pages/series') || id.includes('pages/predictions')) { return 'charts'; } - // Feedback page (includes its own replay viewer) + // Feedback page (includes its own replay viewer + triggers agentation load) if (id.includes('pages/feedback')) { return 'feedback'; } + // Home page (hero, playlists carousel, season bar, evolution mini) + if (id.includes('pages/home')) { + return 'home'; + } + // Leaderboard (rating table) + if (id.includes('pages/leaderboard')) { + return 'leaderboard'; + } }, }, },