feat(web): code splitting per §16.7 — reduce app entry chunk 84% (10KB → 1.6KB gz)

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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-21 12:41:33 -04:00
parent 5215cd7e57
commit c5a83cbe32
8 changed files with 1017 additions and 994 deletions

File diff suppressed because it is too large Load diff

View file

@ -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 = `
<div class="compete-hub-page">
<h1 class="page-title">Compete</h1>
<p class="page-subtitle">Build your bot and climb the ranks</p>
<div class="getting-started">
<h2>Getting Started</h2>
<p>AI Code Battle is a competitive programming platform where you write HTTP bots that control units on a grid world.</p>
</div>
<div class="compete-grid">
<a href="#/compete/sandbox" class="compete-card primary">
<div class="card-icon">🧪</div>
<h2>Test in Sandbox</h2>
<p>Write code and run matches in-browser with no server needed</p>
</a>
<a href="#/compete/register" class="compete-card primary">
<div class="card-icon">🤖</div>
<h2>Register Your Bot</h2>
<p>Sign up your HTTP bot and start competing</p>
</a>
<a href="#/compete/docs" class="compete-card">
<div class="card-icon">📖</div>
<h2>Documentation</h2>
<p>Read the protocol spec and starter kit guides</p>
</a>
<a href="https://github.com/aicodebattle/acb" class="compete-card" target="_blank" rel="noopener">
<div class="card-icon">💻</div>
<h2>Starter Kits</h2>
<p>Example bots in Python, Go, Rust, TypeScript, and more</p>
</a>
<a href="#/leaderboard" class="compete-card">
<div class="card-icon">🏆</div>
<h2>Leaderboard</h2>
<p>See current standings and top performers</p>
</a>
<a href="#/evolution" class="compete-card">
<div class="card-icon">🧬</div>
<h2>Evolution</h2>
<p>Watch bots evolve through genetic algorithms</p>
</a>
</div>
<div class="how-it-works">
<h2>How Competition Works</h2>
<div class="steps">
<div class="step">
<span class="step-number">1</span>
<h3>Build a Bot</h3>
<p>Write an HTTP server that receives game state and returns move commands</p>
</div>
<div class="step">
<span class="step-number">2</span>
<h3>Register</h3>
<p>Submit your bot's endpoint URL and API key to start competing</p>
</div>
<div class="step">
<span class="step-number">3</span>
<h3>Climb the Ranks</h3>
<p>Your bot plays matches automatically and earns rating through Glicko-2</p>
</div>
</div>
</div>
</div>
<style>
.compete-hub-page { max-width: 1200px; margin: 0 auto; }
.page-subtitle { color: var(--text-muted); margin-bottom: 32px; }
.getting-started { background-color: var(--bg-secondary); border-radius: 12px; padding: 24px; margin-bottom: 32px; }
.getting-started h2 { color: var(--text-primary); margin-bottom: 12px; }
.getting-started p { color: var(--text-muted); }
.compete-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin-bottom: 40px; }
.compete-card { background-color: var(--bg-secondary); border-radius: 12px; padding: 32px 24px; text-decoration: none; transition: transform 0.2s, box-shadow 0.2s; display: block; border: 2px solid transparent; }
.compete-card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
.compete-card.primary { border-color: var(--accent); background-color: rgba(59, 130, 246, 0.1); }
.card-icon { font-size: 3rem; margin-bottom: 16px; }
.compete-card h2 { color: var(--text-primary); margin-bottom: 8px; font-size: 1.25rem; }
.compete-card p { color: var(--text-muted); font-size: 0.875rem; }
.how-it-works { background-color: var(--bg-secondary); border-radius: 12px; padding: 32px; }
.how-it-works h2 { color: var(--text-primary); margin-bottom: 24px; }
.steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 24px; }
.step { display: flex; flex-direction: column; gap: 12px; }
.step-number { display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; background-color: var(--accent); color: white; border-radius: 50%; font-weight: 700; font-size: 1.25rem; }
.step h3 { color: var(--text-primary); }
.step p { color: var(--text-muted); font-size: 0.875rem; }
</style>
`;
}

99
web/src/pages/docs.ts Normal file
View file

@ -0,0 +1,99 @@
// Docs/Getting Started page
export function renderDocsPage(): void {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="docs-page">
<h1 class="page-title">Getting Started</h1>
<div class="docs-content">
<section>
<h2>Overview</h2>
<p>AI Code Battle is a competitive bot programming platform. You write an HTTP server that controls units on a grid world, competing against other bots for supremacy.</p>
</section>
<section>
<h2>Game Basics</h2>
<ul>
<li><strong>Grid:</strong> The game is played on a toroidal (wrapping) grid</li>
<li><strong>Units:</strong> Each player controls bots that move one tile per turn</li>
<li><strong>Resources:</strong> Collect energy from nodes to spawn new bots</li>
<li><strong>Objectives:</strong> Capture enemy cores, eliminate opponents, or dominate through numbers</li>
</ul>
</section>
<section>
<h2>HTTP Protocol</h2>
<p>Your bot must expose an HTTPS endpoint that accepts POST requests with JSON game state and returns JSON move commands.</p>
<h3>Request Format</h3>
<pre><code>{
"match_id": "abc123",
"turn": 42,
"player_id": 0,
"config": { ... },
"visible_grid": { ... },
"my_bots": [
{ "id": "bot-1", "position": {"row": 10, "col": 20} }
],
"my_energy": 5,
"my_score": 3
}</code></pre>
<h3>Response Format</h3>
<pre><code>{
"moves": [
{ "bot_id": "bot-1", "direction": "N" }
]
}</code></pre>
<h3>Valid Directions</h3>
<p><code>N</code> (North), <code>E</code> (East), <code>S</code> (South), <code>W</code> (West)</p>
</section>
<section>
<h2>Authentication</h2>
<p>Requests from the game engine are signed with HMAC-SHA256. The signature is sent in the <code>X-Signature</code> header.</p>
<p>Format: <code>{match_id}.{turn}.{timestamp}.{sha256(body)}</code></p>
<p>Your bot should verify signatures using your API key to ensure requests are authentic.</p>
</section>
<section>
<h2>Requirements</h2>
<ul>
<li>HTTPS endpoint accessible from the internet</li>
<li>Response time under 3 seconds per turn</li>
<li>Handle concurrent requests (multiple matches)</li>
<li>Return valid JSON for every request</li>
</ul>
</section>
<section>
<h2>Example Bot</h2>
<p>See the <a href="https://github.com/aicodebattle/acb/tree/main/bots" target="_blank">example bots</a> in various languages for reference implementations.</p>
</section>
<section>
<h2>Data &amp; API</h2>
<p>All match data (leaderboards, replays, bot profiles) is exposed as static JSON files served from CDN.</p>
<p><a href="#/compete/docs" class="btn secondary">View API Reference</a></p>
</section>
</div>
</div>
<style>
.docs-content { max-width: 800px; }
.docs-content section { background-color: var(--bg-secondary); border-radius: 8px; padding: 20px; margin-bottom: 20px; }
.docs-content h2 { color: var(--text-primary); margin-bottom: 12px; }
.docs-content h3 { color: var(--text-primary); margin: 16px 0 8px; font-size: 1rem; }
.docs-content p { color: var(--text-muted); margin-bottom: 10px; }
.docs-content ul { color: var(--text-muted); margin-left: 20px; }
.docs-content li { margin-bottom: 6px; }
.docs-content pre { background-color: var(--bg-primary); border-radius: 6px; padding: 16px; overflow-x: auto; margin: 10px 0; }
.docs-content code { font-family: 'Fira Code', 'Monaco', monospace; font-size: 0.875rem; color: var(--text-secondary); }
.docs-content a { color: var(--accent); }
</style>
`;
}

View file

@ -0,0 +1,20 @@
// 404 page
export function renderNotFoundPage(): void {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="not-found-page">
<h1>404</h1>
<p>Page not found</p>
<a href="#/" class="btn primary">Go Home</a>
</div>
<style>
.not-found-page { text-align: center; padding: 100px 20px; }
.not-found-page h1 { font-size: 4rem; color: var(--text-primary); margin-bottom: 10px; }
.not-found-page p { color: var(--text-muted); margin-bottom: 20px; }
</style>
`;
}

512
web/src/pages/replay.ts Normal file
View file

@ -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<string, string>): void {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="replay-page">
<h1 class="page-title">Replay Viewer</h1>
<div id="replay-loading" style="text-align: center; padding: 60px 20px; color: var(--text-muted);">
Loading replay viewer...
</div>
</div>
`;
loadReplayViewer().then(({ ReplayViewer }) => {
initReplayViewerWithClass(ReplayViewer, params.url);
});
}
function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string): void {
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="replay-page">
<h1 class="page-title">Replay Viewer</h1>
<div class="replay-layout">
<div class="replay-main">
<div class="canvas-wrapper">
<canvas id="replay-canvas"></canvas>
<div id="no-replay" class="no-replay-message">Load a replay file to view</div>
</div>
<div id="win-prob-section" class="win-prob-section" style="display:none">
<div class="win-prob-header">
<span class="win-prob-title">Win Probability</span>
<div class="critical-moment-nav">
<button id="prev-critical-btn" class="btn" title="Previous critical moment" disabled>&#9664; Prev</button>
<span id="critical-moment-info" class="critical-moment-info">&#8212;</span>
<button id="next-critical-btn" class="btn" title="Next critical moment" disabled>Next &#9654;</button>
</div>
</div>
<div id="win-prob-container" class="win-prob-container"></div>
<div class="win-prob-legend">
<span id="wp-p0-label" class="wp-legend-p0">&#8212; Player 0</span>
<span id="wp-p1-label" class="wp-legend-p1">-- Player 1</span>
</div>
</div>
</div>
<div class="replay-sidebar">
<div class="panel">
<h2>Load Replay</h2>
<div class="load-controls">
<div class="file-input-wrapper">
<label class="btn secondary" for="file-input">Choose File</label>
<input type="file" id="file-input" accept=".json" style="display: none;">
</div>
<div class="url-input-group">
<input type="text" id="url-input" placeholder="Or enter URL...">
<button id="load-url-btn" class="btn primary">Load</button>
</div>
</div>
</div>
<div class="panel">
<h2>Playback</h2>
<div class="playback-controls">
<button id="play-btn" class="btn" disabled>Play</button>
<button id="prev-btn" class="btn" disabled>Prev</button>
<button id="next-btn" class="btn" disabled>Next</button>
<button id="reset-btn" class="btn" disabled>Reset</button>
</div>
<div class="slider-group">
<label>Turn: <span id="turn-display">0</span> / <span id="total-turns">0</span></label>
<input type="range" id="turn-slider" min="0" max="0" value="0" disabled>
</div>
<div class="slider-group">
<label>Speed: <span id="speed-display">100</span>ms/turn</label>
<input type="range" id="speed-slider" min="20" max="1000" value="100">
</div>
</div>
<div class="panel">
<h2>View Options</h2>
<div class="view-options">
<label for="fog-select">Fog of War:</label>
<select id="fog-select">
<option value="">Disabled (full view)</option>
</select>
<label for="cell-size-select" style="margin-top: 10px;">Cell Size:</label>
<select id="cell-size-select">
<option value="6">Small (6px)</option>
<option value="8">Medium (8px)</option>
<option value="10" selected>Large (10px)</option>
<option value="12">X-Large (12px)</option>
</select>
</div>
</div>
<div class="panel">
<h2>Accessibility</h2>
<div class="accessibility-options">
<label class="checkbox-label">
<input type="checkbox" id="color-blind-toggle" checked>
Color-blind safe palette
</label>
<label class="checkbox-label">
<input type="checkbox" id="shapes-toggle" checked>
Shapes per player
</label>
<label class="checkbox-label">
<input type="checkbox" id="high-contrast-toggle">
High contrast mode
</label>
<label class="checkbox-label">
<input type="checkbox" id="reduced-motion-toggle">
Reduced motion
</label>
</div>
</div>
<div class="panel">
<h2>Match Info</h2>
<dl class="match-info">
<dt>Match ID</dt>
<dd id="info-match-id">-</dd>
<dt>Winner</dt>
<dd id="info-winner">-</dd>
<dt>Turns</dt>
<dd id="info-turns">-</dd>
<dt>Reason</dt>
<dd id="info-reason">-</dd>
</dl>
</div>
<div class="panel">
<h2>Events This Turn</h2>
<div class="event-log" id="event-log">
<div class="no-events">No events</div>
</div>
</div>
<div class="keyboard-shortcuts">
<kbd>Space</kbd> Play/Pause
<kbd></kbd><kbd></kbd> Step
<kbd>Home</kbd><kbd>End</kbd> First/Last
</div>
</div>
</div>
</div>
<style>
.replay-page .page-title { margin-bottom: 20px; }
.replay-layout { display: flex; gap: 20px; }
.replay-main { flex: 1; min-width: 0; }
.canvas-wrapper { background-color: var(--bg-secondary); border-radius: 8px; padding: 10px; overflow: auto; max-height: 80vh; }
#replay-canvas { display: block; }
.no-replay-message { color: var(--text-muted); text-align: center; padding: 60px 20px; }
.replay-sidebar { width: 300px; flex-shrink: 0; display: flex; flex-direction: column; gap: 15px; }
.panel { background-color: var(--bg-secondary); border-radius: 8px; padding: 15px; }
.panel h2 { font-size: 1rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 12px; }
.load-controls { display: flex; flex-direction: column; gap: 10px; }
.url-input-group { display: flex; gap: 8px; }
.url-input-group input { flex: 1; background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-primary); padding: 8px; border-radius: 6px; font-size: 14px; }
.playback-controls { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.playback-controls .btn:disabled { opacity: 0.5; cursor: not-allowed; }
.slider-group { margin-bottom: 10px; }
.slider-group label { display: block; color: var(--text-muted); font-size: 0.875rem; margin-bottom: 6px; }
.slider-group input[type="range"] { width: 100%; }
.view-options { display: flex; flex-direction: column; }
.view-options label { color: var(--text-muted); font-size: 0.875rem; margin-bottom: 6px; }
.view-options select { background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-primary); padding: 8px; border-radius: 6px; font-size: 14px; }
.accessibility-options { display: flex; flex-direction: column; gap: 8px; }
.checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; color: var(--text-muted); font-size: 0.875rem; }
.checkbox-label input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }
.checkbox-label:hover { color: var(--text-primary); }
.match-info dt { color: var(--text-muted); font-size: 0.75rem; text-transform: uppercase; margin-top: 10px; }
.match-info dd { color: var(--text-primary); }
.event-log { max-height: 150px; overflow-y: auto; font-size: 0.75rem; font-family: monospace; }
.event-log .event { padding: 4px 0; border-bottom: 1px solid var(--bg-tertiary); }
.event-log .event:last-child { border-bottom: none; }
.no-events { color: var(--text-muted); }
.keyboard-shortcuts { font-size: 0.75rem; color: var(--text-muted); }
.keyboard-shortcuts kbd { background-color: var(--bg-tertiary); padding: 2px 6px; border-radius: 4px; font-family: monospace; margin-right: 4px; }
.win-prob-section { background-color: var(--bg-secondary); border-radius: 8px; padding: 12px; margin-top: 10px; }
.win-prob-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; flex-wrap: wrap; gap: 8px; }
.win-prob-title { color: var(--text-muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
.critical-moment-nav { display: flex; align-items: center; gap: 8px; }
.critical-moment-nav .btn { padding: 4px 10px; font-size: 0.75rem; }
.critical-moment-nav .btn:disabled { opacity: 0.4; cursor: not-allowed; }
.critical-moment-info { color: var(--text-muted); font-size: 0.8rem; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.win-prob-container { width: 100%; overflow: hidden; border-radius: 4px; }
.win-prob-legend { display: flex; gap: 16px; margin-top: 6px; font-size: 0.75rem; }
.wp-legend-p0 { color: #3b82f6; }
.wp-legend-p1 { color: #ef4444; }
@media (max-width: 900px) {
.replay-layout { flex-direction: column; }
.replay-sidebar { width: 100%; }
}
</style>
`;
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 = '<div class="no-events">No events</div>';
return;
}
eventLogDiv.innerHTML = events.map((e: GameEvent) => {
const type = e.type.replace(/_/g, ' ');
return `<div class="event"><span style="color: #fbbf24;">${type}</span></div>`;
}).join('');
}
function updateMatchInfo(replay: Replay): void {
infoMatchId.textContent = replay.match_id;
infoTurns.textContent = String(replay.result.turns);
infoReason.textContent = replay.result.reason;
if (replay.result.winner >= 0 && replay.result.winner < replay.players.length) {
infoWinner.textContent = replay.players[replay.result.winner].name;
} else if (replay.result.winner === -1) {
infoWinner.textContent = 'Draw';
} else {
infoWinner.textContent = 'Player ' + replay.result.winner;
}
fogSelect.innerHTML = '<option value="">Disabled (full view)</option>';
replay.players.forEach((player, idx) => {
const option = document.createElement('option');
option.value = String(idx);
option.textContent = player.name;
fogSelect.appendChild(option);
});
}
function loadReplay(replay: Replay): void {
viewer.loadReplay(replay);
enableControls();
updateMatchInfo(replay);
turnSlider.max = String(viewer.getTotalTurns() - 1);
updateUI();
updateEventLog();
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();
}
}

View file

@ -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<string, string>): void {
const seasonId = params.id;
if (!seasonId) {
router.navigate('/seasons');
return;
}
const app = document.getElementById('app');
if (!app) return;
app.innerHTML = `
<div class="season-detail-page">
<div class="breadcrumb">
<a href="#/seasons">Seasons</a> / <span id="season-breadcrumb">Loading...</span>
</div>
<div id="season-content" class="loading">Loading season...</div>
</div>
<style>
.season-detail-page { max-width: 1000px; margin: 0 auto; }
.breadcrumb { color: var(--text-muted); font-size: 0.875rem; margin-bottom: 20px; }
.breadcrumb a { color: var(--accent); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.loading { color: var(--text-muted); text-align: center; padding: 40px; }
.season-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; flex-wrap: wrap; gap: 16px; }
.season-info h1 { font-size: 2rem; color: var(--text-primary); margin-bottom: 8px; }
.season-theme { color: var(--text-muted); font-size: 1rem; }
.season-dates { text-align: right; color: var(--text-muted); font-size: 0.875rem; }
.status-badge { display: inline-block; padding: 4px 12px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; margin-bottom: 8px; }
.status-badge.active { background-color: #22c55e; color: white; }
.status-badge.completed { background-color: #3b82f6; color: white; }
.status-badge.upcoming { background-color: #6b7280; color: white; }
.champion-banner { background: linear-gradient(135deg, rgba(255, 215, 0, 0.1) 0%, rgba(255, 215, 0, 0.05) 100%); border: 1px solid rgba(255, 215, 0, 0.3); border-radius: 12px; padding: 24px; text-align: center; margin-bottom: 32px; }
.champion-crown { font-size: 3rem; margin-bottom: 8px; }
.champion-label { color: var(--text-muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.1em; }
.champion-name { font-size: 1.5rem; color: gold; font-weight: 700; }
.section-title { font-size: 1.25rem; color: var(--text-primary); margin-bottom: 16px; }
.leaderboard-table { width: 100%; border-collapse: collapse; background-color: var(--bg-secondary); border-radius: 8px; overflow: hidden; margin-bottom: 32px; }
.leaderboard-table th, .leaderboard-table td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--bg-tertiary); }
.leaderboard-table th { background-color: var(--bg-tertiary); color: var(--text-muted); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
.leaderboard-table .rank { font-weight: 700; color: var(--text-muted); }
.leaderboard-table tr.rank-1 .rank { color: #fbbf24; }
.leaderboard-table tr.rank-2 .rank { color: #94a3b8; }
.leaderboard-table tr.rank-3 .rank { color: #cd7f32; }
.season-rules { background-color: var(--bg-tertiary); border-radius: 8px; padding: 20px; }
.season-rules h4 { color: var(--text-primary); margin-bottom: 12px; }
.season-rules ul { margin-left: 20px; color: var(--text-muted); }
.season-rules li { margin-bottom: 6px; }
</style>
`;
loadSeasonDetail(seasonId);
}
async function loadSeasonDetail(seasonId: string): Promise<void> {
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 = `
<div class="season-header">
<div class="season-info">
<h1>${escapeHtml(season.name)}</h1>
<p class="season-theme">${escapeHtml(season.theme)}</p>
</div>
<div class="season-dates">
<span class="status-badge ${season.status}">${season.status}</span>
<div>Started: ${new Date(season.starts_at).toLocaleDateString()}</div>
${season.ends_at ? `<div>Ended: ${new Date(season.ends_at).toLocaleDateString()}</div>` : ''}
</div>
</div>
${season.champion_name ? `
<div class="champion-banner">
<div class="champion-crown">👑</div>
<div class="champion-label">Champion</div>
<div class="champion-name">${escapeHtml(season.champion_name)}</div>
</div>
` : ''}
${season.final_snapshot && season.final_snapshot.length > 0 ? `
<h2 class="section-title">Final Leaderboard</h2>
<table class="leaderboard-table">
<thead>
<tr>
<th>Rank</th>
<th>Bot</th>
<th>Rating</th>
<th>Wins</th>
<th>Losses</th>
</tr>
</thead>
<tbody>
${season.final_snapshot.map((entry: any) => `
<tr class="rank-${entry.rank}">
<td class="rank">#${entry.rank}</td>
<td>${escapeHtml(entry.bot_name)}</td>
<td>${Math.round(entry.rating)}</td>
<td>${entry.wins}</td>
<td>${entry.losses}</td>
</tr>
`).join('')}
</tbody>
</table>
` : ''}
<div class="season-rules">
<h4>Rules Version: ${season.rules_version}</h4>
<ul>
<li>Standard 60×60 toroidal grid</li>
<li>500 turn limit</li>
<li>Glicko-2 rating system</li>
<li>Best-of-1 matches</li>
</ul>
</div>
`;
} catch (err) {
console.error('Failed to load season:', err);
content.innerHTML = `
<div class="error">
<p>Failed to load season: ${seasonId}</p>
<p class="hint">The season may not exist yet.</p>
<a href="#/seasons" class="btn primary">Back to Seasons</a>
</div>
`;
}
}

View file

@ -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 = `
<div class="watch-hub-page">
<h1 class="page-title">Watch</h1>
<p class="page-subtitle">Spectate matches, browse replays, and make predictions</p>
<div class="watch-grid">
<a href="#/watch/replays" class="watch-card">
<div class="card-icon">📺</div>
<h2>Match Replays</h2>
<p>Browse all completed matches and watch replays</p>
</a>
<a href="#/watch/predictions" class="watch-card">
<div class="card-icon">🎯</div>
<h2>Predictions</h2>
<p>Predict match winners and climb the predictor leaderboard</p>
</a>
<a href="#/leaderboard" class="watch-card">
<div class="card-icon">🏆</div>
<h2>Leaderboard</h2>
<p>See current rankings and top bots</p>
</a>
</div>
<div class="featured-section">
<h2>Featured Playlists</h2>
<div id="featured-playlists" class="playlists-preview">
<div class="loading">Loading playlists...</div>
</div>
</div>
</div>
<style>
.watch-hub-page { max-width: 1200px; margin: 0 auto; }
.page-subtitle { color: var(--text-muted); margin-bottom: 32px; }
.watch-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; margin-bottom: 40px; }
.watch-card { background-color: var(--bg-secondary); border-radius: 12px; padding: 32px 24px; text-decoration: none; transition: transform 0.2s, box-shadow 0.2s; display: block; }
.watch-card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
.card-icon { font-size: 3rem; margin-bottom: 16px; }
.watch-card h2 { color: var(--text-primary); margin-bottom: 8px; font-size: 1.25rem; }
.watch-card p { color: var(--text-muted); font-size: 0.875rem; }
.featured-section { margin-top: 40px; }
.featured-section h2 { color: var(--text-primary); margin-bottom: 16px; }
.playlists-preview { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; }
.playlist-preview-card { background-color: var(--bg-secondary); border-radius: 8px; padding: 16px; text-decoration: none; transition: transform 0.2s; }
.playlist-preview-card:hover { transform: translateY(-2px); }
.playlist-preview-card h3 { color: var(--text-primary); font-size: 1rem; margin-bottom: 4px; }
.playlist-preview-card p { color: var(--text-muted); font-size: 0.75rem; }
.loading { color: var(--text-muted); text-align: center; padding: 40px; grid-column: 1 / -1; }
</style>
`;
loadFeaturedPlaylists();
}
async function loadFeaturedPlaylists(): Promise<void> {
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 = '<p style="color: var(--text-muted);">No playlists available yet.</p>';
return;
}
const featured = data.playlists.slice(0, 4);
container.innerHTML = featured.map((p: any) => `
<a href="#/watch/replays" class="playlist-preview-card">
<h3>${escapeHtml(p.title)}</h3>
<p>${p.match_count} matches</p>
</a>
`).join('');
} catch {
container.innerHTML = '<p style="color: var(--text-muted);">Failed to load playlists.</p>';
}
}

View file

@ -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';
}
},
},
},