diff --git a/web/index.html b/web/index.html index e34e038..c75765a 100644 --- a/web/index.html +++ b/web/index.html @@ -879,6 +879,7 @@ Compete Leaderboard Evolution + Maps Blog Season 1 @@ -890,6 +891,7 @@
Evolution + Maps Blog Season 1
diff --git a/web/src/api-types.ts b/web/src/api-types.ts index 54e4dab..afed243 100644 --- a/web/src/api-types.ts +++ b/web/src/api-types.ts @@ -605,3 +605,29 @@ export async function fetchMapVotes(mapId: string): Promise { if (!response.ok) throw new Error(`Failed to fetch map votes: ${response.status}`); return response.json(); } + +// Map browsing types (§14.6) + +export interface MapData { + map_id: string; + player_count: number; + status: string; + engagement: number; + wall_density: number; + energy_count: number; + grid_width: number; + grid_height: number; + net_votes: number; + created_at: string; +} + +export interface MapsIndex { + updated_at: string; + maps: MapData[]; +} + +export async function fetchMapsIndex(): Promise { + const response = await fetch('/maps/index.json'); + if (!response.ok) throw new Error(`Failed to fetch maps index: ${response.status}`); + return response.json(); +} diff --git a/web/src/app.ts b/web/src/app.ts index 3b9572f..8a13d80 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -42,6 +42,7 @@ function getSkeletonHtml(path: string): string { if (path === '/evolution') return skeletonEvolution(); if (path.startsWith('/blog')) return skeletonBlog(); if (path === '/seasons' || path.startsWith('/season/')) return skeletonSeasons(); + if (path === '/maps') return skeletonGeneric('Maps'); if (path === '/rivalries' || path.startsWith('/rivalry/')) return skeletonGeneric('Rivalries'); if (path === '/watch/predictions' || path === '/predictions') return skeletonGeneric('Predictions'); if (path === '/watch') return skeletonGeneric('Watch'); @@ -76,6 +77,7 @@ const loadDocsDataPage = () => import('./pages/docs-data').then(m => m.renderDoc // Bot-related pages const loadBotProfilePage = () => import('./pages/bot-profile').then(m => m.renderBotProfilePage); const loadEvolutionPage = () => import('./pages/evolution').then(m => m.renderEvolutionPage); +const loadMapsPage = () => import('./pages/maps').then(m => m.renderMapsPage); // Blog & seasons const loadBlogPages = () => import('./pages/blog').then(m => ({ renderBlogPage: m.renderBlogPage, renderBlogPostPage: m.renderBlogPostPage })); @@ -292,6 +294,7 @@ router .on('/rivalries', lazyRoute(loadRivalriesPage)) .on('/rivalry/:bot_a/:bot_b', lazyRoute(loadRivalryPage)) .on('/embed/:id', lazyRoute(loadEmbedPage)) + .on('/maps', lazyRoute(loadMapsPage)) .notFound(lazyRoute(loadNotFoundPage)); // ─── Initialization ──────────────────────────────────────────────────────────────── diff --git a/web/src/pages/maps.ts b/web/src/pages/maps.ts new file mode 100644 index 0000000..efeaa26 --- /dev/null +++ b/web/src/pages/maps.ts @@ -0,0 +1,356 @@ +// Maps browsing page — view all maps, stats, and vote (§14.6) + +import { fetchMapsIndex, submitMapVote, fetchMapVotes, type MapData } from '../api-types'; + +let mapsData: MapData[] = []; +let mapVotes: Map = new Map(); + +export async function renderMapsPage(): Promise { + const app = document.getElementById('app'); + if (!app) return; + + app.innerHTML = ` +
+

Maps

+

Browse all competitive maps, view engagement scores, and vote on your favorites

+
Loading maps...
+
+ `; + + const content = document.getElementById('maps-content'); + if (!content) return; + + try { + const data = await fetchMapsIndex(); + mapsData = data.maps; + renderMapsGrid(content); + } catch (err) { + content.innerHTML = ` +
+

Failed to load maps.

+

${err instanceof Error ? err.message : 'Unknown error'}

+
+ `; + } +} + +function renderMapsGrid(container: HTMLElement): void { + // Group maps by player count + const byPlayerCount: Record = {}; + for (const map of mapsData) { + if (!byPlayerCount[map.player_count]) { + byPlayerCount[map.player_count] = []; + } + byPlayerCount[map.player_count].push(map); + } + + // Sort each group by engagement (descending) + for (const count of Object.keys(byPlayerCount)) { + byPlayerCount[Number(count)].sort((a, b) => b.engagement - a.engagement); + } + + let html = ''; + + for (const [count, maps] of Object.entries(byPlayerCount).sort(([a], [b]) => Number(a) - Number(b))) { + html += ` +
+

${count}-Player Maps

+
+ `; + + for (const map of maps) { + html += renderMapCard(map); + } + + html += ` +
+
+ `; + } + + container.innerHTML = html; + attachMapCardListeners(); +} + +function renderMapCard(map: MapData): string { + const voteData = mapVotes.get(map.map_id); + const netVotes = voteData?.net_votes ?? map.net_votes; + const myVote = voteData?.my_vote; + + return ` +
+
+ ${escapeHtml(map.map_id)} + ${map.player_count}P +
+ +
+
+ Engagement + ${map.engagement.toFixed(1)} +
+
+ Wall Density + ${(map.wall_density * 100).toFixed(0)}% +
+
+ Energy + ${map.energy_count} +
+
+ Size + ${map.grid_width}×${map.grid_height} +
+
+ +
+ + ${netVotes > 0 ? '+' : ''}${netVotes} + +
+ +
${map.status}
+
+ `; +} + +function attachMapCardListeners(): void { + // Vote buttons + for (const card of document.querySelectorAll('.map-card')) { + const mapId = card.getAttribute('data-map-id'); + if (!mapId) continue; + + for (const btn of card.querySelectorAll('.vote-btn')) { + btn.addEventListener('click', async (e) => { + e.preventDefault(); + const vote = (e.currentTarget as HTMLElement).getAttribute('data-vote'); + if (!vote) return; + + const voteNum = vote === '1' ? 1 : -1; + await handleVote(mapId, voteNum, card as HTMLElement); + }); + } + } +} + +async function handleVote(mapId: string, vote: 1 | -1, card: HTMLElement): Promise { + const voteData = mapVotes.get(mapId); + + // If clicking the same vote, remove it + const newVote = voteData?.my_vote === vote ? 0 : vote; + + try { + // Optimistic update + const currentNet = voteData?.net_votes ?? (mapsData.find(m => m.map_id === mapId)?.net_votes ?? 0); + const oldMyVote = voteData?.my_vote ?? 0; + + // Update net votes: subtract old vote, add new vote + const newNet = currentNet - oldMyVote + newVote; + + mapVotes.set(mapId, { my_vote: newVote === 0 ? undefined : newVote, net_votes: newNet }); + + // Re-render just this card + const map = mapsData.find(m => m.map_id === mapId); + if (map) { + card.outerHTML = renderMapCard({ ...map, net_votes: newNet }); + attachMapCardListeners(); + } + + // Submit to API (if not removing vote) + if (newVote !== 0) { + await submitMapVote(mapId, newVote); + } else { + // To remove a vote, we'd need to vote the opposite direction and then vote again + // For now, just submit the new vote + await submitMapVote(mapId, vote); + } + + // Refresh vote data from server to confirm + const fresh = await fetchMapVotes(mapId); + mapVotes.set(mapId, { my_vote: fresh.my_vote, net_votes: fresh.net_votes }); + + // Re-render with confirmed data + const freshMap = mapsData.find(m => m.map_id === mapId); + if (freshMap) { + const freshCard = document.querySelector(`[data-map-id="${mapId}"]`); + if (freshCard) { + freshCard.outerHTML = renderMapCard({ ...freshMap, net_votes: fresh.net_votes }); + attachMapCardListeners(); + } + } + } catch (err) { + console.error('Failed to submit vote:', err); + alert('Failed to submit vote. Please try again.'); + } +} + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// ─── Styles ──────────────────────────────────────────────────────────────────────── + +document.head.insertAdjacentHTML('beforeend', ` + +`);