diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1553e26 --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# Vector — environment variables (P0.1) +# Copy to `.env.local` and fill in real values. Never commit `.env*` (see .gitignore). +# Validation runs at startup (lib/config/env.ts); a missing/malformed REQUIRED +# value crashes the server with a redacted message. Secrets are server-only and +# never enter the client bundle. + +# --- Required --------------------------------------------------------------- +# Neon (Postgres) connection string. Must be postgres:// or postgresql://. +DATABASE_URL= + +# --- Optional (validated only if set; needed in later stages) ---------------- +# Mantle testnet RPC URL (http(s) or ws(s)). On-chain stages (P1.7+). +# Public default works for reads: https://rpc.sepolia.mantle.xyz +MANTLE_TESTNET_RPC_URL= +# Nansen smart-money API key (P2.2). Secret. +NANSEN_API_KEY= +# Elfa social-signal API key (P3.1). Secret. +ELFA_API_KEY= +# Owner key: registers seed agents in the ERC-8004 Identity Registry. Secret. +OPERATOR_PRIVATE_KEY= +# Attestor key: authors ERC-8004 feedback writes (P1.8). Secret. +# MUST be a different address than OPERATOR_PRIVATE_KEY — the registry rejects +# self-feedback from an agent's owner/operator. +ATTESTOR_PRIVATE_KEY= +# Deployed commit SHA surfaced by /api/health. Non-secret. +GIT_COMMIT= +# Absolute base URL the off-chain attestation detail is served from, e.g. +# https://vector.app — used to build the ERC-8004 feedbackURI (P1.8). Non-secret. +PUBLIC_BASE_URL= + +# --- Byreal perps rail (P2.1) — optional; DEFAULT-OFF, fails closed ----------- +# The rail enables only when BOTH the agent key and wallet address are set; +# absent => deterministic seed settlement (byte-identical scores). +# Byreal/Hyperliquid agent API key. Secret; scoped to the CLI child env only. +BYREAL_PERPS_AGENT_KEY= +# Byreal/Hyperliquid wallet address (0x…40 hex) the rail trades from. +BYREAL_PERPS_WALLET_ADDRESS= +# Network the rail may trade on: testnet | mainnet. Defaults to testnet. Safety +# boundary — the adapter refuses to construct with mainnet creds unless this is +# explicitly set to mainnet. +BYREAL_PERPS_NETWORK= +# Explicit path to the @byreal-io/byreal-perps-cli entry; else package-resolved. +BYREAL_PERPS_CLI_PATH= diff --git a/gitignore b/.gitignore similarity index 93% rename from gitignore rename to .gitignore index cd849df..9aaf214 100644 --- a/gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ # ============================================================================= # ----- Dependencies ---------------------------------------------------------- +# Match both a node_modules directory and a stray node_modules symlink/file +# (a trailing-slash pattern ignores only directories). +node_modules node_modules/ .pnp .pnp.* @@ -146,8 +149,7 @@ temp/ # docs will be ignored. Prefer ignoring only GENERATED docs instead, e.g.: # docs/_generated/ # docs/api/ -# Uncomment the broad rule only if docs/ is fully auto-generated: -docs/ +# Ignore only GENERATED docs; handwritten docs/*.md are committed deliverables. docs/_generated/ docs/.vitepress/cache/ docs/.vitepress/dist/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..4710c2f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +.next/ +node_modules/ +coverage/ +bun.lock +*.md +# Generated by `bun run api:openapi`; that script owns its formatting (and keeps +# it byte-deterministic for CI diffing), so Prettier must not reflow it. +docs/openapi.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..4cbc711 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2 +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..d369305 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# Vector + +**The merit layer for autonomous capital on Mantle.** + +A bounded-execution **referee** (firewall) + reputation **scoring** (AgentScore +0–100) + a reputation-weighted **capital router**, anchored on-chain by an +ERC-8004 Reputation Registry on Mantle testnet. Demo rail: Byreal Perps CLI. +The product is a deterministic 90-second arc: merit → blocked theft → +reputation collapse → capital reroute. + +Built for **Mantle: The Turing Test Hackathon 2026** — track *Agentic Wallets & +Economy*. + +## The pipeline + +Vector is not three demos — it is one real, deterministic pipeline. The same +referee, scoring and router used in production drive a frozen 90-second arc, so +the demo is honest (no mocked verdicts) and reproducible (same seed ⇒ +byte-identical run). + +``` +signal → decide → intent → referee → execution → outcome + │ + score (AgentScore 0–100) + │ + on-chain anchor (ERC-8004 on Mantle) + │ + capital re-route (pool conserved) +``` + +- **Referee (firewall).** A pure, deterministic gate over *typed, signed Intents* + (never prompts). A fixed, ordered rule set reduces each Intent to + `HALT/REJECT/CLIP/ALLOW`; blocking rules dominate soft clips. Rule #3 + `fresh_wallet_transfer_block` rejects a drain to a non-whitelisted wallet + `hard` — the load-bearing security property. See [docs/referee.md](./docs/referee.md). +- **AgentScore ∈ [0,100].** A pure scoring function whose *only* exposure input is + capital-at-risk (`car_r`), not trade count or volume — the structural root of + its anti-wash / anti-Sybil property. A confirmed drain floor-crashes the score. + See [docs/scoring.md](./docs/scoring.md). +- **Reputation-weighted capital router.** Moves a fixed, *exactly conserved* + capital pool toward the highest scores in bounded, stable steps; a blocked + theft drains the offender and reroutes to the honest leaders. See + [docs/capital-router.md](./docs/capital-router.md). + +## On Mantle (on-chain) + +Per-round agent feedback is anchored on the **canonical ERC-8004 Reputation +Registry** already deployed on **Mantle Sepolia** (`chainId 5003`). Vector reads/ +writes the shared singletons — it does not deploy its own. + +| Contract | Address | +| ------------------ | -------------------------------------------- | +| ReputationRegistry | `0x8004B663056A597Dffe9eCcC1965A193B7388713` | +| IdentityRegistry | `0x8004A818BFB912233c491871b3d84c89A494BD9e` | + +`giveFeedback(...)` is authorized by `msg.sender` (no off-chain signature); the +feedback author must differ from the agent's owner/operator. See +[docs/erc8004-registry.md](./docs/erc8004-registry.md). + +**Real venue, not a sim:** allowed Intents can settle on the real Byreal Perps +testnet venue via the official `@byreal-io/byreal-perps-cli` — a real on-venue +order id + PnL next to the demo. Byreal fills and the read-only Nansen +smart-money signal are optional side-channels that **never** feed the +deterministic scoring arc (default-off ⇒ byte-identical run). See +[docs/byreal-rail.md](./docs/byreal-rail.md) and [docs/nansen-signal.md](./docs/nansen-signal.md). + +## Demo — the 90-second arc + +Two seed agents (`seed-leader`, `seed-2`). On the penultimate round an operator +injects a fund-draining `transfer` from the leader → the referee blocks it → +scoring crashes the leader → the router reroutes its capital to the honest +runner-up, pool conserved to the last unit. Entry point `runArc(db, DEMO_ARC)`; +determinism is pinned by golden + e2e + fuzz + integration tests. See +[docs/demo-spine.md](./docs/demo-spine.md). + +## Stack + +Next.js (App Router) · TypeScript (strict) · Neon/Postgres · SWR polling (no +sockets) · zod · **Bun** (package manager, runtime, test runner). + +## Quickstart + +Requires [Bun](https://bun.sh) ≥ 1.3. + +```bash +bun install +cp .env.example .env.local # set DATABASE_URL (Neon postgres:// string) +bun run dev # http://localhost:3000 +``` + +Health: `GET /api/health` runs a real `SELECT 1` and returns +`{ ok, db, config_loaded, commit }` (200 up / 503 down). + +## Scripts + +```bash +bun run dev | build | start +bun run typecheck # tsc --noEmit +bun run lint # eslint . +bun run test # unit + fuzz + e2e (integration auto-skips w/o DB) +bun run test:integration # needs DATABASE_URL; run in its own process +``` + +> ⚠️ `bun run build` requires a valid `DATABASE_URL`. The API route modules +> read `ENV` at import, which is validated eagerly when Next collects page data +> at build time, so a build without the variable fails fast. This is expected +> for the Vercel deploy (where `DATABASE_URL` is set); set it locally to build. + +## Docs + +- [docs/demo-spine.md](./docs/demo-spine.md) — the deterministic 90-second arc. +- [docs/referee.md](./docs/referee.md) · [docs/scoring.md](./docs/scoring.md) · + [docs/capital-router.md](./docs/capital-router.md) — the three core engines. +- [docs/erc8004-registry.md](./docs/erc8004-registry.md) — on-chain ERC-8004 + integration on Mantle Sepolia. +- [docs/byreal-rail.md](./docs/byreal-rail.md) · [docs/nansen-signal.md](./docs/nansen-signal.md) + — the credibility rail and smart-money signal (optional side-channels). +- [docs/config.md](./docs/config.md) — every constant, default and §ARCH ref. +- [docs/env.md](./docs/env.md) — env variables, formats, secret handling. +- [docs/adr/0001-…](./docs/adr/0001-seeded-config-and-swr-polling.md) — why one + seeded config + SWR polling (not sockets). diff --git a/app/api/agents/[id]/route.ts b/app/api/agents/[id]/route.ts new file mode 100644 index 0000000..c9900b1 --- /dev/null +++ b/app/api/agents/[id]/route.ts @@ -0,0 +1,62 @@ +import type { NextRequest } from 'next/server'; + +import { + type AgentDetailDto, + toAgentDto, + toIntentDto, + toOutcomeDto, + toPolicyEventDto, + toScoreDto, +} from '@/lib/api/dto'; +import { NotFoundError } from '@/lib/api/errors'; +import { parseLimit, parseUuid } from '@/lib/api/query'; +import { ok, route } from '@/lib/api/respond'; +import { getPool } from '@/lib/db/client'; +import { getAgent } from '@/lib/db/repos/agents'; +import { listIntentsByAgent } from '@/lib/db/repos/intents'; +import { listRecentOutcomesByAgent } from '@/lib/db/repos/outcomes'; +import { listRecentPolicyEventsByAgent } from '@/lib/db/repos/policy-events'; +import { listScoreHistoryByAgent } from '@/lib/db/repos/scores'; + +/** + * `GET /api/agents/[id]` — one agent's detail: its EWMA score history (oldest + * round first), recent intents, the referee decisions on them, and recent + * outcomes. The UI correlates an intent with its decision by `intent_id`, so the + * lists are returned side by side rather than as a fragile nested join. + * + * A malformed `id` is `400 invalid_id`; a well-formed id matching no agent is + * `404 agent_not_found` — the two are kept distinct so an id probe never reads + * as a real "not found". `?limit=` bounds the recent intents/events/outcomes; + * the score history is bounded by the number of rounds. + */ +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +export function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }): Promise { + return route(async () => { + const id = parseUuid((await ctx.params).id); + const limit = parseLimit(new URL(req.url).searchParams.get('limit')); + const db = getPool(); + + const agent = await getAgent(db, id); + if (agent === null) { + throw new NotFoundError('agent not found', 'agent_not_found'); + } + + const [scores, intents, policyEvents, outcomes] = await Promise.all([ + listScoreHistoryByAgent(db, id), + listIntentsByAgent(db, id, limit), + listRecentPolicyEventsByAgent(db, id, limit), + listRecentOutcomesByAgent(db, id, limit), + ]); + + const payload: AgentDetailDto = { + agent: toAgentDto(agent), + scores: scores.map(toScoreDto), + intents: intents.map(toIntentDto), + policy_events: policyEvents.map(toPolicyEventDto), + outcomes: outcomes.map(toOutcomeDto), + }; + return ok(payload); + }); +} diff --git a/app/api/attestations/[id]/feedback/route.ts b/app/api/attestations/[id]/feedback/route.ts new file mode 100644 index 0000000..c3f92d1 --- /dev/null +++ b/app/api/attestations/[id]/feedback/route.ts @@ -0,0 +1,48 @@ +import { NextResponse, type NextRequest } from 'next/server'; + +import { NotFoundError } from '@/lib/api/errors'; +import { parseUuid } from '@/lib/api/query'; +import { route } from '@/lib/api/respond'; +import { getPool } from '@/lib/db/client'; +import { getAttestationById } from '@/lib/db/repos/attestations'; + +/** + * `GET /api/attestations/[id]/feedback` — the off-chain ERC-8004 feedback + * **detail** for one attestation, served from Neon at the on-chain `feedbackURI`. + * + * The body is the **exact stored bytes** (`attestations.feedback_detail`), not a + * re-serialization, so `KECCAK-256(body)` always equals the on-chain + * `feedback_hash` — a client can fetch this, hash it, and prove integrity. The + * hash is echoed in `X-Feedback-Hash` / `ETag` for convenience; it is *not* the + * source of truth (the body is). A malformed `id` is `400`; a well-formed id + * with no attestation, or one whose detail has not been built yet, is `404`. + */ +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +export function GET( + _req: NextRequest, + ctx: { params: Promise<{ id: string }> }, +): Promise { + return route(async () => { + const id = parseUuid((await ctx.params).id); + const row = await getAttestationById(getPool(), id); + if (row === null || row.feedback_detail === null) { + throw new NotFoundError('attestation detail not found', 'attestation_not_found'); + } + return new NextResponse(row.feedback_detail, { + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + // The body is served as the *exact* stored bytes for hash verification, + // so pin the declared type: never let a browser MIME-sniff the payload + // into another (e.g. executable) content type. + 'X-Content-Type-Options': 'nosniff', + 'Cache-Control': 'no-store', + ...(row.feedback_hash !== null + ? { 'X-Feedback-Hash': row.feedback_hash, ETag: `"${row.feedback_hash}"` } + : {}), + }, + }); + }); +} diff --git a/app/api/attestations/route.ts b/app/api/attestations/route.ts new file mode 100644 index 0000000..9ce9c3a --- /dev/null +++ b/app/api/attestations/route.ts @@ -0,0 +1,32 @@ +import type { NextRequest } from 'next/server'; + +import { toAttestationDto } from '@/lib/api/dto'; +import { parseChainState, parseCursor, parseLimit } from '@/lib/api/query'; +import { ok, paginate, route } from '@/lib/api/respond'; +import { getPool } from '@/lib/db/client'; +import { listAttestationsPage } from '@/lib/db/repos/attestations'; + +/** + * `GET /api/attestations` — ERC-8004 attestation records mirrored in Neon, + * newest first, with their `chain_state` (`optimistic`/`confirmed`/`failed`), + * `tx_hash`, and `block_number`. Optional `?chain_state=` filter and keyset + * `?cursor=` pagination; `?limit=` bounds the page. + */ +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +export function GET(req: NextRequest): Promise { + return route(async () => { + const params = new URL(req.url).searchParams; + const limit = parseLimit(params.get('limit')); + const chainState = parseChainState(params.get('chain_state')); + const cursor = parseCursor(params.get('cursor')); + + const rows = await listAttestationsPage(getPool(), { + limit, + ...(chainState !== undefined ? { chainState } : {}), + ...(cursor !== null ? { before: cursor } : {}), + }); + return ok(paginate(rows, toAttestationDto, limit)); + }); +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..1a34390 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from 'next/server'; + +import { checkDb } from '@/lib/db/client'; +import { buildHealthPayload, healthStatusCode } from '@/lib/health'; + +/** + * Health endpoint. Runs a real `SELECT 1` against Neon and reports liveness, + * config-loaded status and the deployed commit. Always dynamic (never cached) + * and on the Node.js runtime because it opens a database connection. + */ +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +export async function GET(): Promise { + const db = await checkDb(); + const payload = buildHealthPayload({ db, commit: process.env.GIT_COMMIT }); + return NextResponse.json(payload, { status: healthStatusCode(db) }); +} diff --git a/app/api/leaderboard/route.ts b/app/api/leaderboard/route.ts new file mode 100644 index 0000000..4f41abb --- /dev/null +++ b/app/api/leaderboard/route.ts @@ -0,0 +1,36 @@ +import type { NextRequest } from 'next/server'; + +import { CONFIG } from '@/lib/config/constants'; +import { type LeaderboardDto, toLeaderboardEntryDto, toRoundDto } from '@/lib/api/dto'; +import { parseLimit } from '@/lib/api/query'; +import { ok, route } from '@/lib/api/respond'; +import { getPool } from '@/lib/db/client'; +import { listLeaderboard } from '@/lib/db/repos/leaderboard'; +import { getLatestRound } from '@/lib/db/repos/rounds'; + +/** + * `GET /api/leaderboard` — agents ranked by current AgentScore, each with its + * capital allocation in the current round, plus the round's status. Read-only; + * the single writer of `agents.score_current` is the scoring engine. + * + * Always dynamic and on the Node runtime because it opens a database connection. + */ +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +export function GET(req: NextRequest): Promise { + return route(async () => { + const limit = parseLimit(new URL(req.url).searchParams.get('limit')); + const db = getPool(); + + const round = await getLatestRound(db); + const rows = await listLeaderboard(db, round?.id ?? null, limit); + + const payload: LeaderboardDto = { + round: round === null ? null : toRoundDto(round), + capital_unit: CONFIG.capital.capital_unit_label, + data: rows.map(toLeaderboardEntryDto), + }; + return ok(payload); + }); +} diff --git a/app/api/policy-events/route.ts b/app/api/policy-events/route.ts new file mode 100644 index 0000000..9654513 --- /dev/null +++ b/app/api/policy-events/route.ts @@ -0,0 +1,27 @@ +import type { NextRequest } from 'next/server'; + +import { toPolicyEventDto } from '@/lib/api/dto'; +import { parseCursor, parseLimit } from '@/lib/api/query'; +import { ok, paginate, route } from '@/lib/api/respond'; +import { getPool } from '@/lib/db/client'; +import { listPolicyEventsPage } from '@/lib/db/repos/policy-events'; + +/** + * `GET /api/policy-events` — the red-alert feed of referee decisions + * (REJECT/HALT/CLIP/ALLOW) across all agents, newest first. Keyset-paginated via + * `?cursor=` so a freshly written REJECT/HALT appears at the head within one + * poll without paging skipping or repeating rows. `?limit=` bounds the page. + */ +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +export function GET(req: NextRequest): Promise { + return route(async () => { + const params = new URL(req.url).searchParams; + const limit = parseLimit(params.get('limit')); + const cursor = parseCursor(params.get('cursor')); + + const rows = await listPolicyEventsPage(getPool(), limit, cursor ?? undefined); + return ok(paginate(rows, toPolicyEventDto, limit)); + }); +} diff --git a/app/arena/AgentRow.tsx b/app/arena/AgentRow.tsx new file mode 100644 index 0000000..27888f3 --- /dev/null +++ b/app/arena/AgentRow.tsx @@ -0,0 +1,94 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import { formatCapital, formatScore, truncateName, type AgentView } from '@/lib/arena'; +import styles from './arena.module.css'; + +/** Map an agent status to its pill class. */ +const STATUS_CLASS: Record = { + active: styles.statusActive!, + gated: styles.statusGated!, + halted: styles.statusHalted!, +}; + +export interface AgentRowProps { + readonly agent: AgentView; + readonly capitalUnit: string; + /** Reputation collapsed this poll — redden and empty the bars. */ + readonly crashed: boolean; + /** A REJECT/HALT implicated this agent this poll — fire the row flash. */ + readonly flashed: boolean; + /** Capital-bar transition duration (ms), from `flowDurationMs`. */ + readonly barDurationMs: number; +} + +/** + * One leaderboard row: rank, identity, a score bar and a capital bar, and the + * exact capital figure. The bars' *widths* come from the float fractions + * (geometry); the score and capital *text* come from the exact decimal strings + * (precision). The capital bar's width animates over `barDurationMs` so a + * reallocation reads as capital draining or filling. + */ +export function AgentRow({ + agent, + capitalUnit, + crashed, + flashed, + barDurationMs, +}: AgentRowProps): ReactNode { + const rowClass = [ + styles.row, + agent.rank === 0 ? styles.leaderRow : '', + crashed ? styles.crashed : '', + flashed ? styles.flashRow : '', + ] + .filter(Boolean) + .join(' '); + + return ( +
  • + {agent.rank + 1} + + + + {truncateName(agent.displayName)} + + {agent.status} + + + {agent.owner} + + + + + + + + + + + score {formatScore(agent.score)} + + + + + + {formatCapital(agent.allocation, 0)} + {' '} + {capitalUnit} + +
  • + ); +} diff --git a/app/arena/Arena.tsx b/app/arena/Arena.tsx new file mode 100644 index 0000000..2d685b6 --- /dev/null +++ b/app/arena/Arena.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; + +import { CONFIG } from '@/lib/config/constants'; +import { + deriveFlows, + deriveScoreChanges, + flowDurationMs, + rankAgents, + selectFlashes, + summarizeFlashes, + type AgentSnapshot, +} from '@/lib/arena'; +import { useLeaderboard, usePolicyFeed, usePrevious, useReducedMotion } from './hooks'; +import { Leaderboard } from './Leaderboard'; +import { RedFlash } from './RedFlash'; +import styles from './arena.module.css'; + +const POOL = CONFIG.capital.pool_size; +const EMPTY_IDS: ReadonlySet = new Set(); + +interface FlashState { + readonly key: number; + readonly count: number; + readonly agentIds: ReadonlySet; +} + +/** + * The live Arena. Two SWR feeds poll at the single `ui_poll_ms` cadence; every + * animation is derived by diffing the current poll against the previous one with + * the pure helpers in `lib/arena`: + * + * - capital-flow → bar widths animate, durations scaled by move size vs `max_step`; + * - reputation-drop → crashed agents redden/empty and fall in rank (FLIP); + * - red-flash → a screen overlay + per-row flash fire within one poll of a + * REJECT/HALT, de-duplicated by event id so each block flashes exactly once. + * + * The screen degrades gracefully: a feed error shows a banner but never tears + * down the board, and a transient `undefined` between revalidations is ignored. + */ +export function Arena(): ReactNode { + const { data: lb, error: lbError, isLoading } = useLeaderboard(); + const { data: feed } = usePolicyFeed(); + const reducedMotion = useReducedMotion(); + + const agents = useMemo(() => (lb ? rankAgents(lb.data, POOL) : []), [lb]); + const prevSnapshot = usePrevious(lb?.data); + + // Capital-flow + reputation-drop, derived from the previous poll. + const { crashedIds, barDurations } = useMemo(() => { + const crashed = new Set(); + const durations = new Map(); + if (prevSnapshot && lb) { + const timing = { maxStep: CONFIG.router.max_step, pollMs: CONFIG.timing.ui_poll_ms }; + for (const f of deriveFlows(prevSnapshot, lb.data, POOL)) { + if (f.direction !== 'none') + durations.set(f.agentId, flowDurationMs(f.deltaFraction, timing)); + } + for (const c of deriveScoreChanges(prevSnapshot, lb.data, CONFIG.scoring.crash_cap)) { + if (c.isCrash) crashed.add(c.agentId); + } + } + return { crashedIds: crashed as ReadonlySet, barDurations: durations }; + }, [prevSnapshot, lb]); + + // Red-flash state, threaded across polls by event id. + const seenRef = useRef>(EMPTY_IDS); + const initRef = useRef(false); + const keyRef = useRef(0); + const [flash, setFlash] = useState({ key: 0, count: 0, agentIds: EMPTY_IDS }); + + useEffect(() => { + if (!feed) return; + // First load establishes the baseline: existing blocks are history, not new. + if (!initRef.current) { + initRef.current = true; + seenRef.current = selectFlashes(feed.data, EMPTY_IDS).seen; + return; + } + const { flashes, seen } = selectFlashes(feed.data, seenRef.current); + seenRef.current = seen; + if (flashes.length > 0) { + const summary = summarizeFlashes(flashes); + keyRef.current += 1; + setFlash({ key: keyRef.current, count: summary.count, agentIds: summary.agentIds }); + } + }, [feed]); + + // Clear the per-row flash shortly after it fires so a later block can re-fire it. + useEffect(() => { + if (flash.agentIds.size === 0) return; + const t = setTimeout(() => setFlash((f) => ({ ...f, agentIds: EMPTY_IDS })), 800); + return () => clearTimeout(t); + }, [flash]); + + const round = lb?.round ?? null; + const capitalUnit = lb?.capital_unit ?? CONFIG.capital.capital_unit_label; + + return ( +
    +
    +

    Vector Arena

    + + {round ? ( + <> + Round {round.index} {round.state} + + ) : ( + no round yet + )} + +
    + + {lbError ? ( +

    + Leaderboard unavailable — retrying… +

    + ) : isLoading && agents.length === 0 ? ( +

    Loading the arena…

    + ) : agents.length === 0 ? ( +

    No agents in the arena yet.

    + ) : ( + + )} + + +
    + ); +} diff --git a/app/arena/Leaderboard.tsx b/app/arena/Leaderboard.tsx new file mode 100644 index 0000000..9fe03ea --- /dev/null +++ b/app/arena/Leaderboard.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useRef, type ReactNode } from 'react'; + +import type { AgentView } from '@/lib/arena'; +import { AgentRow } from './AgentRow'; +import { useFlip } from './useFlip'; +import styles from './arena.module.css'; + +export interface LeaderboardProps { + readonly agents: readonly AgentView[]; + readonly capitalUnit: string; + /** Agents whose reputation collapsed this poll. */ + readonly crashedIds: ReadonlySet; + /** Agents implicated by a REJECT/HALT this poll. */ + readonly flashedIds: ReadonlySet; + /** Per-agent capital-bar transition duration (ms); falls back to a default. */ + readonly barDurations: ReadonlyMap; + readonly reducedMotion: boolean; +} + +const DEFAULT_BAR_MS = 600; + +/** + * The ranked board. Rows are keyed by agent id and ordered by rank; when the + * order changes between polls, {@link useFlip} animates the slide so an agent + * visibly falls or climbs. The FLIP pass is re-run whenever the ordered id list + * changes, and is a no-op under reduced motion. + */ +export function Leaderboard({ + agents, + capitalUnit, + crashedIds, + flashedIds, + barDurations, + reducedMotion, +}: LeaderboardProps): ReactNode { + const ref = useRef(null); + const order = agents.map((a) => a.id).join(','); + useFlip(ref, [order], reducedMotion); + + return ( +
      + {agents.map((agent) => ( + + ))} +
    + ); +} diff --git a/app/arena/RedFlash.tsx b/app/arena/RedFlash.tsx new file mode 100644 index 0000000..bbd4f39 --- /dev/null +++ b/app/arena/RedFlash.tsx @@ -0,0 +1,46 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import styles from './arena.module.css'; + +export interface RedFlashProps { + /** + * A monotonically-changing key that increments each poll a REJECT/HALT fires. + * Changing the key re-mounts the overlay so its one-shot CSS animation replays; + * an unchanged key means no new block, so the overlay stays dormant. + */ + readonly flashKey: number; + /** How many policy blocks fired in the triggering poll (for the banner copy). */ + readonly count: number; +} + +/** + * The screen-level red-flash on a policy block. It is a non-interactive overlay + * plus a short-lived banner; both are keyed on `flashKey` so a *new* block + * replays the animation while a steady feed head does not. Under reduced motion + * the overlay holds a static red vignette instead of strobing (see the CSS). + */ +export function RedFlash({ flashKey, count }: RedFlashProps): ReactNode { + if (flashKey === 0) return null; + const label = count > 1 ? `${count} POLICY BLOCKS` : 'POLICY BLOCK'; + return ( + <> +