feat(web): win probability sparkline + critical moment navigation
Add §13.2 win probability graph and critical moment navigation to the replay viewer. - types.ts: add ReplayCriticalMoment interface; extend Replay with win_prob ([][]float per turn) and critical_moments fields - replay-viewer.ts: export CriticalMomentMarker; draw dashed vertical lines with delta labels for critical moments on the sparkline canvas; add setCriticalMoments / getCriticalMomentMarkers; update createWinProbSparkline to accept click-to-scrub callback and replace existing canvas on reload; refresh sparkline on every render() - app.ts: add win-probability section below main canvas with prev/next critical moment buttons, description label, and player legend; initWinProb converts win_prob array to WinProbPoint[] and wires up setCriticalMoments; graceful hide when win_prob absent Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
72addb2089
commit
9850020c53
3 changed files with 993 additions and 37 deletions
913
web/src/app.ts
913
web/src/app.ts
|
|
@ -20,46 +20,123 @@ import { renderPredictionsPage } from './pages/predictions';
|
|||
import { ReplayViewer } from './replay-viewer';
|
||||
import type { Replay } from './types';
|
||||
|
||||
// Route definitions
|
||||
// Backwards compatibility redirects
|
||||
const redirectMap: Record<string, string> = {
|
||||
'/matches': '/watch/replays',
|
||||
'/playlists': '/watch/replays',
|
||||
'/replay': '/watch/replay',
|
||||
'/predictions': '/watch/predictions',
|
||||
'/series': '/watch/series',
|
||||
'/sandbox': '/compete/sandbox',
|
||||
'/register': '/compete/register',
|
||||
'/bots': '/leaderboard',
|
||||
'/docs': '/compete/docs',
|
||||
'/docs/api': '/compete/docs',
|
||||
'/clip-maker': '/watch/replays',
|
||||
'/rivalries': '/watch/replays',
|
||||
'/feedback': '/compete/docs',
|
||||
};
|
||||
|
||||
// Helper to redirect to new route
|
||||
function redirect(to: string): (params: Record<string, string>) => void {
|
||||
return (params: Record<string, string>) => {
|
||||
const fullPath = Object.entries(params).reduce(
|
||||
(path, [key, value]) => path.replace(`:${key}`, encodeURIComponent(value)),
|
||||
to
|
||||
);
|
||||
router.navigate(fullPath);
|
||||
};
|
||||
}
|
||||
|
||||
// Route definitions with new Watch/Compete hub structure
|
||||
router
|
||||
// Main routes
|
||||
.on('/', renderHomePage)
|
||||
.on('/watch', renderWatchHubPage)
|
||||
.on('/watch/replays', renderMatchesPage)
|
||||
.on('/watch/replay/:id', renderReplayPage)
|
||||
.on('/watch/series/:id', renderSeriesPage)
|
||||
.on('/watch/predictions', renderPredictionsPage)
|
||||
.on('/watch/series', renderSeriesPage)
|
||||
.on('/compete', renderCompeteHubPage)
|
||||
.on('/compete/sandbox', renderSandboxPage)
|
||||
.on('/compete/register', renderRegisterPage)
|
||||
.on('/compete/bot/:id', renderBotProfilePage)
|
||||
.on('/compete/docs', renderDocsPage)
|
||||
.on('/leaderboard', renderLeaderboardPage)
|
||||
.on('/matches', renderMatchesPage)
|
||||
.on('/bots', renderBotsPage)
|
||||
.on('/bot/:id', renderBotProfilePage)
|
||||
.on('/register', renderRegisterPage)
|
||||
.on('/evolution', renderEvolutionPage)
|
||||
.on('/sandbox', renderSandboxPage)
|
||||
.on('/clip-maker', renderClipMakerPage)
|
||||
.on('/rivalries', renderRivalriesPage)
|
||||
.on('/feedback', renderFeedbackPage)
|
||||
.on('/playlists', renderPlaylistsPage)
|
||||
.on('/blog', renderBlogPage)
|
||||
.on('/blog/:slug', renderBlogPostPage)
|
||||
.on('/replay', renderReplayPage)
|
||||
.on('/docs', renderDocsPage)
|
||||
.on('/docs/api', renderDocsPage)
|
||||
.on('/season/:id', renderSeasonDetailPage)
|
||||
.on('/seasons', renderSeasonsPage)
|
||||
.on('/series', renderSeriesPage)
|
||||
.on('/predictions', renderPredictionsPage)
|
||||
.on('/bot/:id', renderBotProfilePage)
|
||||
// Backwards compatibility redirects
|
||||
.on('/matches', redirect('/watch/replays'))
|
||||
.on('/playlists', redirect('/watch/replays'))
|
||||
.on('/replay', redirect('/watch/replay'))
|
||||
.on('/predictions', redirect('/watch/predictions'))
|
||||
.on('/series', redirect('/watch/series'))
|
||||
.on('/sandbox', redirect('/compete/sandbox'))
|
||||
.on('/register', redirect('/compete/register'))
|
||||
.on('/bots', redirect('/leaderboard'))
|
||||
.on('/docs', redirect('/compete/docs'))
|
||||
.on('/docs/api', redirect('/compete/docs'))
|
||||
.on('/clip-maker', redirect('/watch/replays'))
|
||||
.on('/rivalries', redirect('/watch/replays'))
|
||||
.on('/feedback', redirect('/compete/docs'))
|
||||
.notFound(renderNotFoundPage);
|
||||
|
||||
// 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 '#/'
|
||||
if (currentPath === linkPath || (linkPath !== '' && currentPath.startsWith(linkPath))) {
|
||||
// Check for exact match or prefix match for hub pages
|
||||
if (currentPath === linkPath ||
|
||||
(linkPath !== '' && currentPath.startsWith(linkPath)) ||
|
||||
(linkPath === '/watch' && currentPath.startsWith('/watch')) ||
|
||||
(linkPath === '/compete' && currentPath.startsWith('/compete'))) {
|
||||
link.classList.add('active');
|
||||
} else {
|
||||
link.classList.remove('active');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Mobile menu toggle
|
||||
function initMobileMenu(): void {
|
||||
const toggle = document.getElementById('mobile-menu-toggle');
|
||||
const menu = document.getElementById('mobile-menu');
|
||||
|
||||
if (!toggle || !menu) return;
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
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);
|
||||
menu.classList.remove('open');
|
||||
};
|
||||
}
|
||||
|
||||
// Override router navigation to update nav links
|
||||
const originalNavigate = router.navigate.bind(router);
|
||||
router.navigate = (path: string) => {
|
||||
|
|
@ -82,6 +159,21 @@ function renderReplayPage(params: Record<string, string>): void {
|
|||
<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>◀ Prev</button>
|
||||
<span id="critical-moment-info" class="critical-moment-info">—</span>
|
||||
<button id="next-critical-btn" class="btn" title="Next critical moment" disabled>Next ▶</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">— Player 0</span>
|
||||
<span id="wp-p1-label" class="wp-legend-p1">-- Player 1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="replay-sidebar">
|
||||
|
|
@ -379,6 +471,76 @@ function renderReplayPage(params: Record<string, string>): void {
|
|||
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;
|
||||
|
|
@ -417,8 +579,16 @@ function initReplayViewer(initialUrl?: string): void {
|
|||
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 ReplayViewer(canvas, { cellSize: 10 });
|
||||
let criticalMoments: Array<{turn: number; delta: number; description: string}> = [];
|
||||
|
||||
function enableControls(): void {
|
||||
playBtn.disabled = false;
|
||||
|
|
@ -480,8 +650,82 @@ function initReplayViewer(initialUrl?: string): void {
|
|||
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, t) => ({
|
||||
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) => {
|
||||
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 => 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 => 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 => 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;
|
||||
|
|
@ -533,8 +777,11 @@ function initReplayViewer(initialUrl?: string): void {
|
|||
const size = parseInt(cellSizeSelect.value, 10);
|
||||
const replay = viewer.getReplay();
|
||||
if (replay) {
|
||||
const prevTurn = viewer.getTurn();
|
||||
viewer = new ReplayViewer(canvas, { cellSize: size });
|
||||
loadReplay(replay);
|
||||
viewer.setTurn(prevTurn);
|
||||
updateUI();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -564,7 +811,11 @@ function initReplayViewer(initialUrl?: string): void {
|
|||
updateAccessibility();
|
||||
}
|
||||
|
||||
viewer.onTurnChange = () => { updateUI(); updateEventLog(); };
|
||||
viewer.onTurnChange = () => {
|
||||
updateUI();
|
||||
updateEventLog();
|
||||
if (criticalMoments.length > 0) updateCriticalMomentNav();
|
||||
};
|
||||
viewer.onPlayStateChange = (playing) => { playBtn.textContent = playing ? 'Pause' : 'Play'; };
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
|
|
@ -608,6 +859,630 @@ function initReplayViewer(initialUrl?: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
// Watch hub page - spectator hub with replays, playlists, predictions
|
||||
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>
|
||||
`;
|
||||
|
||||
// Load featured playlists
|
||||
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>';
|
||||
}
|
||||
}
|
||||
|
||||
// Compete hub page - participant hub with sandbox, register, docs
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
// Season detail page - standalone page for viewing a specific season
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Docs/Getting Started page
|
||||
function renderDocsPage(): void {
|
||||
const app = document.getElementById('app');
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ export interface WinProbPoint {
|
|||
drawProb?: number;
|
||||
}
|
||||
|
||||
export interface CriticalMomentMarker {
|
||||
turn: number;
|
||||
delta: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Render win probability sparkline to canvas
|
||||
export function renderWinProbSparkline(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
|
|
@ -18,9 +24,10 @@ export function renderWinProbSparkline(
|
|||
height: number;
|
||||
color0?: string;
|
||||
color1?: string;
|
||||
criticalMoments?: CriticalMomentMarker[];
|
||||
},
|
||||
): void {
|
||||
const { width, height, color0 = '#3b82f6', color1 = '#ef4444' } = options;
|
||||
const { width, height, color0 = '#3b82f6', color1 = '#ef4444', criticalMoments = [] } = options;
|
||||
const padding = { top: 8, bottom: 8, left: 4, right: 4 };
|
||||
const chartW = width - padding.left - padding.right;
|
||||
const chartH = height - padding.top - padding.bottom;
|
||||
|
|
@ -51,6 +58,40 @@ export function renderWinProbSparkline(
|
|||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Critical moment markers — dashed vertical lines with delta labels
|
||||
for (const moment of criticalMoments) {
|
||||
const mx = x(moment.turn);
|
||||
const markerColor = moment.delta > 0 ? color0 : color1;
|
||||
|
||||
ctx.strokeStyle = markerColor + 'aa';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(mx, padding.top);
|
||||
ctx.lineTo(mx, height - padding.bottom);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Small diamond at midpoint
|
||||
const my = height / 2;
|
||||
const s = 3;
|
||||
ctx.fillStyle = markerColor;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(mx, my - s);
|
||||
ctx.lineTo(mx + s, my);
|
||||
ctx.lineTo(mx, my + s);
|
||||
ctx.lineTo(mx - s, my);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Delta label near top
|
||||
const label = `${moment.delta > 0 ? '+' : ''}${(moment.delta * 100).toFixed(0)}%`;
|
||||
ctx.fillStyle = markerColor;
|
||||
ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(label, Math.max(18, Math.min(width - 18, mx)), padding.top + 7);
|
||||
}
|
||||
|
||||
// P0 area fill
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, y(0.5));
|
||||
|
|
@ -606,6 +647,11 @@ export class ReplayViewer {
|
|||
const events = turnData.events ?? [];
|
||||
this.announceToScreenReader(this.generateTurnDescription(events));
|
||||
}
|
||||
|
||||
// Keep sparkline current-turn marker in sync
|
||||
if (this.winProbCanvas && this.winProbData) {
|
||||
this.renderWinProbSparkline();
|
||||
}
|
||||
}
|
||||
|
||||
// Standard view with grid
|
||||
|
|
@ -1193,40 +1239,66 @@ export class ReplayViewer {
|
|||
|
||||
private winProbData: WinProbPoint[] | null = null;
|
||||
private winProbCanvas: HTMLCanvasElement | null = null;
|
||||
private winProbCriticalMoments: CriticalMomentMarker[] = [];
|
||||
|
||||
// Set win probability data for sparkline rendering
|
||||
setWinProbabilityData(points: WinProbPoint[]): void {
|
||||
this.winProbData = points;
|
||||
if (this.winProbCanvas) {
|
||||
this.renderWinProbSparkline();
|
||||
}
|
||||
if (this.winProbCanvas) this.renderWinProbSparkline();
|
||||
}
|
||||
|
||||
// Get the win probability data
|
||||
getWinProbabilityData(): WinProbPoint[] | null {
|
||||
return this.winProbData;
|
||||
}
|
||||
|
||||
// Create and attach a win probability sparkline canvas
|
||||
createWinProbSparkline(container: HTMLElement, width?: number, height = 60): HTMLCanvasElement {
|
||||
this.winProbCanvas = document.createElement('canvas');
|
||||
this.winProbCanvas.width = width ?? container.clientWidth;
|
||||
this.winProbCanvas.height = height;
|
||||
this.winProbCanvas.className = 'win-prob-sparkline-canvas';
|
||||
this.winProbCanvas.style.cssText = 'width:100%;height:' + height + 'px;border-radius:6px;';
|
||||
container.appendChild(this.winProbCanvas);
|
||||
setCriticalMoments(moments: CriticalMomentMarker[]): void {
|
||||
this.winProbCriticalMoments = moments;
|
||||
if (this.winProbCanvas) this.renderWinProbSparkline();
|
||||
}
|
||||
|
||||
if (this.winProbData) {
|
||||
this.renderWinProbSparkline();
|
||||
getCriticalMomentMarkers(): CriticalMomentMarker[] {
|
||||
return this.winProbCriticalMoments;
|
||||
}
|
||||
|
||||
// Create and attach a win probability sparkline canvas below the main viewer.
|
||||
// Pass onTurnClick to enable click-to-scrub: clicking anywhere on the sparkline
|
||||
// calls onTurnClick with the nearest turn number.
|
||||
createWinProbSparkline(
|
||||
container: HTMLElement,
|
||||
width?: number,
|
||||
height = 70,
|
||||
onTurnClick?: (turn: number) => void,
|
||||
): HTMLCanvasElement {
|
||||
// Replace any existing canvas
|
||||
if (this.winProbCanvas && this.winProbCanvas.parentElement === container) {
|
||||
container.removeChild(this.winProbCanvas);
|
||||
}
|
||||
|
||||
this.winProbCanvas = document.createElement('canvas');
|
||||
this.winProbCanvas.width = width ?? Math.max(container.clientWidth, 400);
|
||||
this.winProbCanvas.height = height;
|
||||
this.winProbCanvas.className = 'win-prob-sparkline-canvas';
|
||||
this.winProbCanvas.style.cssText = `width:100%;height:${height}px;border-radius:6px;cursor:pointer;`;
|
||||
container.appendChild(this.winProbCanvas);
|
||||
|
||||
if (onTurnClick) {
|
||||
this.winProbCanvas.addEventListener('click', (e) => {
|
||||
if (!this.winProbData || this.winProbData.length < 2 || !this.winProbCanvas) return;
|
||||
const rect = this.winProbCanvas.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) * (this.winProbCanvas.width / rect.width);
|
||||
const padding = 4;
|
||||
const chartW = this.winProbCanvas.width - padding * 2;
|
||||
const maxTurn = this.winProbData[this.winProbData.length - 1].turn;
|
||||
const turn = Math.round(Math.max(0, Math.min(maxTurn, (x - padding) / chartW * maxTurn)));
|
||||
onTurnClick(turn);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.winProbData) this.renderWinProbSparkline();
|
||||
return this.winProbCanvas;
|
||||
}
|
||||
|
||||
// Render the sparkline
|
||||
private renderWinProbSparkline(): void {
|
||||
if (!this.winProbCanvas || !this.winProbData || this.winProbData.length < 2) return;
|
||||
|
||||
const ctx = this.winProbCanvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
|
|
@ -1235,6 +1307,7 @@ export class ReplayViewer {
|
|||
height: this.winProbCanvas.height,
|
||||
color0: this.accessibility.highContrast ? '#0000ff' : '#3b82f6',
|
||||
color1: this.accessibility.highContrast ? '#ff0000' : '#ef4444',
|
||||
criticalMoments: this.winProbCriticalMoments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,12 @@ export interface ReplayTurn {
|
|||
debug?: Record<number, DebugInfo>;
|
||||
}
|
||||
|
||||
export interface ReplayCriticalMoment {
|
||||
turn: number;
|
||||
delta: number; // change in p0 win probability (positive = p0 improved)
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Replay {
|
||||
format_version?: string; // semver, e.g. "1.0" — absent in pre-v1 replays
|
||||
match_id: string;
|
||||
|
|
@ -82,6 +88,8 @@ export interface Replay {
|
|||
players: ReplayPlayer[];
|
||||
map: ReplayMap;
|
||||
turns: ReplayTurn[];
|
||||
win_prob?: number[][]; // [[p0, p1], ...] one entry per turn
|
||||
critical_moments?: ReplayCriticalMoment[];
|
||||
}
|
||||
|
||||
// Event detail types
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue