diff --git a/site/beacon/index.html b/site/beacon/index.html index 25415c738..46df11acc 100644 --- a/site/beacon/index.html +++ b/site/beacon/index.html @@ -70,6 +70,11 @@ + + +
DRAG rotate | SCROLL zoom | RIGHT-DRAG pan
diff --git a/site/beacon/leaderboard.mjs b/site/beacon/leaderboard.mjs new file mode 100644 index 000000000..fcaa00cfa --- /dev/null +++ b/site/beacon/leaderboard.mjs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT + +const MAX_METRIC = 1_000_000_000; +const DEFAULT_LIMIT = 10; +const MAX_LIMIT = 50; + +function safeCount(value) { + const count = Number(value); + if (!Number.isFinite(count) || count <= 0) return 0; + return Math.min(MAX_METRIC, Math.floor(count)); +} + +function safeLimit(value) { + const limit = Number(value); + if (!Number.isFinite(limit) || limit <= 0) return DEFAULT_LIMIT; + return Math.min(MAX_LIMIT, Math.floor(limit)); +} + +function reputationFor(reputation, agentId) { + if (reputation instanceof Map) return reputation.get(agentId) || {}; + if (!reputation || typeof reputation !== 'object') return {}; + return reputation[agentId] || {}; +} + +function visibleContractCounts(contracts) { + const counts = new Map(); + const seenByAgent = new Map(); + + for (const [index, contract] of (Array.isArray(contracts) ? contracts : []).entries()) { + if (!contract || typeof contract !== 'object') continue; + const contractKey = String(contract.id ?? `row-${index}`); + const participants = new Set([contract.from, contract.to].filter(value => ( + typeof value === 'string' && value.length > 0 + ))); + + for (const agentId of participants) { + if (!seenByAgent.has(agentId)) seenByAgent.set(agentId, new Set()); + const seen = seenByAgent.get(agentId); + if (seen.has(contractKey)) continue; + seen.add(contractKey); + counts.set(agentId, (counts.get(agentId) || 0) + 1); + } + } + + return counts; +} + +function compareText(left, right) { + const a = String(left ?? '').toLowerCase(); + const b = String(right ?? '').toLowerCase(); + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + +/** + * Build a deterministic leaderboard without mutating live Atlas data. + * + * Contract totals use the larger of currently-visible relationships and the + * backend's completed-contract counter so historical work remains represented. + */ +export function buildAgentLeaderboard( + agents, + contracts, + reputation = {}, + sortBy = 'beats', + limit = DEFAULT_LIMIT, +) { + const contractCounts = visibleContractCounts(contracts); + const entries = []; + const seenAgentIds = new Set(); + + for (const agent of (Array.isArray(agents) ? agents : [])) { + if (!agent || typeof agent !== 'object') continue; + const id = typeof agent.id === 'string' ? agent.id : ''; + if (!id || seenAgentIds.has(id)) continue; + seenAgentIds.add(id); + + const rep = reputationFor(reputation, id); + const beats = safeCount(agent.beat_count); + const contractCount = Math.max( + safeCount(contractCounts.get(id)), + safeCount(rep.contracts_completed), + ); + + if (beats === 0 && contractCount === 0) continue; + entries.push({ + id, + name: typeof agent.name === 'string' && agent.name.trim() ? agent.name.trim() : id, + beats, + contracts: contractCount, + }); + } + + const mode = sortBy === 'contracts' ? 'contracts' : 'beats'; + const secondary = mode === 'beats' ? 'contracts' : 'beats'; + entries.sort((a, b) => ( + b[mode] - a[mode] + || b[secondary] - a[secondary] + || compareText(a.name, b.name) + || compareText(a.id, b.id) + )); + + return entries.slice(0, safeLimit(limit)); +} diff --git a/site/beacon/styles.css b/site/beacon/styles.css index 05b060fc1..922d8335d 100644 --- a/site/beacon/styles.css +++ b/site/beacon/styles.css @@ -90,6 +90,125 @@ html, body { color: var(--green); } +/* --- Agent Leaderboard (left sidebar) --- */ +.agent-leaderboard { + position: fixed; + top: 150px; + left: 16px; + z-index: 50; + width: min(340px, calc(100vw - 32px)); + max-height: calc(100vh - 220px); + display: flex; + flex-direction: column; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 4px; + box-shadow: 0 0 20px rgba(0, 255, 0, 0.05); + color: var(--text-body); + font-family: var(--font-mono); + pointer-events: auto; + overflow: hidden; +} + +.leaderboard-header { + padding: 8px 10px; + background: rgba(0, 20, 0, 0.65); + border-bottom: 1px solid var(--border); +} + +.leaderboard-title { + color: var(--amber); + font-family: var(--font-display); + font-size: 20px; + font-weight: 400; + letter-spacing: 1px; +} + +.leaderboard-controls { + display: flex; + gap: 6px; + margin-top: 5px; +} + +.leaderboard-mode { + padding: 2px 7px; + background: transparent; + border: 1px solid var(--border); + border-radius: 2px; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 10px; + cursor: pointer; +} + +.leaderboard-mode[aria-pressed="true"] { + border-color: var(--amber-dim); + color: var(--amber); + background: rgba(255, 176, 0, 0.08); +} + +.leaderboard-list { + min-height: 34px; + margin: 0; + padding: 0; + list-style: none; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--green-dim) transparent; +} + +.leaderboard-item { + margin: 0; + padding: 0; +} + +.leaderboard-row { + width: 100%; + display: grid; + grid-template-columns: 26px minmax(0, 1fr) 54px 54px; + gap: 5px; + align-items: center; + padding: 6px 9px; + background: transparent; + border: 0; + border-bottom: 1px solid rgba(26, 51, 26, 0.65); + color: var(--text-body); + font-family: var(--font-mono); + font-size: 11px; + text-align: left; + cursor: pointer; +} + +.leaderboard-row:hover, +.leaderboard-row:focus-visible { + background: rgba(51, 255, 51, 0.08); + color: var(--green); + outline: 1px solid var(--green-dim); + outline-offset: -1px; +} + +.leaderboard-rank { + color: var(--amber); +} + +.leaderboard-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.leaderboard-metric { + color: var(--text-dim); + text-align: right; + font-variant-numeric: tabular-nums; +} + +.leaderboard-empty { + padding: 10px; + color: var(--text-dim); + font-size: 11px; +} + /* --- Controls hint (bottom-left) --- */ .controls-hint { position: fixed; @@ -753,6 +872,17 @@ html, body { .hud-title { font-size: 22px; } .hud-stats { font-size: 14px; } + .agent-leaderboard { + top: 132px; + left: 8px; + width: min(330px, calc(100vw - 16px)); + max-height: 36vh; + } + + .leaderboard-row { + padding: 5px 7px; + } + .controls-hint { display: none; } } diff --git a/site/beacon/ui.js b/site/beacon/ui.js index bcc27d986..e2aee4a81 100644 --- a/site/beacon/ui.js +++ b/site/beacon/ui.js @@ -11,6 +11,7 @@ import { getAgentPosition, highlightAgent } from './agents.js'; import { getCityCenter } from './cities.js'; import { highlightAgentConnections, addContractLine } from './connections.js'; import { initChat, setCurrentAgent, getChatHTML, bindChatEvents } from './chat.js'; +import { buildAgentLeaderboard } from './leaderboard.mjs'; const BEACON_API = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') ? 'http://localhost:8071' @@ -20,15 +21,8 @@ let panel, panelContent, panelPath, tooltip; let selectedAgent = null; let selectedCity = null; let hoveredId = null; - -function escapeHtml(value) { - return String(value ?? '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} +let leaderboardRoot = null; +let leaderboardMode = 'beats'; function safeNumber(value, fallback = 0, min = 0, max = Number.MAX_SAFE_INTEGER) { const number = Number(value); @@ -89,6 +83,7 @@ async function loadReputation() { }); } reputationTs = Date.now(); + renderAgentLeaderboard(); } } catch (e) { console.warn('[rep] Failed to load reputation:', e.message); @@ -111,6 +106,7 @@ export function initUI() { // HUD stats updateHUD(); + initAgentLeaderboard(); // Click handlers setClickHandler(onObjectClick); @@ -155,6 +151,94 @@ function updateHUD() { } } +function initAgentLeaderboard() { + leaderboardRoot = document.querySelector('.agent-leaderboard'); + renderAgentLeaderboard(); +} + +function renderAgentLeaderboard() { + if (!leaderboardRoot) return; + + const header = document.createElement('div'); + header.className = 'leaderboard-header'; + + const title = document.createElement('h2'); + title.className = 'leaderboard-title'; + title.textContent = '[TOP AGENTS]'; + header.appendChild(title); + + const controls = document.createElement('div'); + controls.className = 'leaderboard-controls'; + for (const mode of ['beats', 'contracts']) { + const modeButton = document.createElement('button'); + modeButton.type = 'button'; + modeButton.className = 'leaderboard-mode'; + modeButton.textContent = mode === 'beats' ? 'BEATS' : 'CONTRACTS'; + modeButton.setAttribute('aria-pressed', String(leaderboardMode === mode)); + modeButton.addEventListener('click', () => { + leaderboardMode = mode; + renderAgentLeaderboard(); + }); + controls.appendChild(modeButton); + } + header.appendChild(controls); + + const entries = buildAgentLeaderboard( + AGENTS, + CONTRACTS, + reputationCache, + leaderboardMode, + 10, + ); + const list = document.createElement('ol'); + list.className = 'leaderboard-list'; + + if (entries.length === 0) { + const empty = document.createElement('li'); + empty.className = 'leaderboard-empty'; + empty.textContent = 'No heartbeat or contract activity yet.'; + list.appendChild(empty); + } + + entries.forEach((entry, index) => { + const item = document.createElement('li'); + item.className = 'leaderboard-item'; + + const row = document.createElement('button'); + row.type = 'button'; + row.className = 'leaderboard-row'; + row.setAttribute( + 'aria-label', + `${index + 1}. ${entry.name}; ${entry.beats} heartbeats; ${entry.contracts} contracts`, + ); + row.addEventListener('click', () => selectAgent(entry.id)); + + const rank = document.createElement('span'); + rank.className = 'leaderboard-rank'; + rank.textContent = String(index + 1).padStart(2, '0'); + + const rowName = document.createElement('span'); + rowName.className = 'leaderboard-name'; + rowName.textContent = entry.name; + + const beats = document.createElement('span'); + beats.className = 'leaderboard-metric'; + beats.title = 'Heartbeats'; + beats.textContent = `${entry.beats} B`; + + const contracts = document.createElement('span'); + contracts.className = 'leaderboard-metric'; + contracts.title = 'Contracts'; + contracts.textContent = `${entry.contracts} C`; + + row.append(rank, rowName, beats, contracts); + item.appendChild(row); + list.appendChild(item); + }); + + leaderboardRoot.replaceChildren(header, list); +} + function setPanelPath(path) { const prompt = document.createElement('span'); prompt.className = 'prompt'; @@ -643,6 +727,7 @@ async function submitContract() { // Success - add to data, create 3D line, update HUD const normalized = addContract(data); addContractLine(normalized); + renderAgentLeaderboard(); updateHUD(); successEl.textContent = `CONTRACT ${data.id} TRANSMITTED. State: ${data.state}`; diff --git a/tests/beacon_agent_leaderboard.test.mjs b/tests/beacon_agent_leaderboard.test.mjs new file mode 100644 index 000000000..af228351e --- /dev/null +++ b/tests/beacon_agent_leaderboard.test.mjs @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MIT + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildAgentLeaderboard } from '../site/beacon/leaderboard.mjs'; + +const agents = [ + { id: 'bcn_beta', name: 'Beta', beat_count: 8 }, + { id: 'bcn_alpha', name: 'Alpha', beat_count: 8 }, + { id: 'bcn_gamma', name: 'Gamma', beat_count: 3 }, + { id: 'bcn_idle', name: 'Idle', beat_count: 0 }, +]; + +const contracts = [ + { id: 'ctr-1', from: 'bcn_alpha', to: 'bcn_gamma' }, + { id: 'ctr-2', from: 'bcn_gamma', to: 'bcn_beta' }, + { id: 'ctr-3', from: 'bcn_gamma', to: 'bcn_gamma' }, +]; + +test('beat mode ranks by heartbeats, then contracts, then name', () => { + const ranked = buildAgentLeaderboard(agents, contracts, {}, 'beats'); + + assert.deepEqual(ranked.map(entry => entry.id), [ + 'bcn_alpha', + 'bcn_beta', + 'bcn_gamma', + ]); + assert.deepEqual(ranked.map(entry => entry.beats), [8, 8, 3]); +}); + +test('contract mode combines visible relationships with reputation history', () => { + const ranked = buildAgentLeaderboard( + agents, + contracts, + { bcn_beta: { contracts_completed: 7 } }, + 'contracts', + ); + + assert.equal(ranked[0].id, 'bcn_beta'); + assert.equal(ranked[0].contracts, 7); + assert.equal(ranked.find(entry => entry.id === 'bcn_gamma').contracts, 3); +}); + +test('duplicate contract ids and self-contracts count once per agent', () => { + const ranked = buildAgentLeaderboard( + [{ id: 'bcn_one', name: 'One', beat_count: 1 }], + [ + { id: 'same', from: 'bcn_one', to: 'bcn_one' }, + { id: 'same', from: 'bcn_one', to: 'bcn_other' }, + ], + {}, + 'contracts', + ); + + assert.equal(ranked[0].contracts, 1); +}); + +test('malformed counts fail closed and source arrays are not mutated', () => { + const sourceAgents = [ + { id: 'bcn_bad', name: 'Bad', beat_count: '