diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22ded44..6a7bf35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,10 @@ jobs: if: github.event_name == 'pull_request' needs: [test] runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 diff --git a/.gitignore b/.gitignore index 9932b3e..d7e10f8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ apps/web/dist/ # Dependencies node_modules/ +.data/ # Env .env diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index e6986d6..2cfeec5 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,15 +3,46 @@ import { streamSSE } from "hono/streaming"; import type { BattleEvent } from "@agent-arena/contracts"; import fixture from "../../../examples/fixtures/hackathon-001.json"; import verifiedShowcase from "../../../examples/fixtures/verified-showcase.json"; -import { runLiveBattleFromPayload, LiveBattleIdeaTooLongError } from "@/lib/runtime/runLiveBattleFromPayload"; -import { StepFunNotConfiguredError } from "@/lib/runtime/providers/stepfun"; +import { runLiveBattleFromPayload } from "@/lib/runtime/runLiveBattleFromPayload"; import { BattleRateLimiter } from "./middlewares/rate-limit"; import { isLiveBattleEnabled } from "./middlewares/feature-flag"; +import { LocalLiveBattleStore } from "./live-battle-store"; export const app = new Hono(); const liveBattleRateLimiter = new BattleRateLimiter(); -const liveBattlesInFlight = new Map(); +const liveBattleStore = new LocalLiveBattleStore(); +type LiveMessage = { type: "battle"; event: BattleEvent } | { type: "done" } | { type: "error"; error: string }; +const liveSubscribers = new Map Promise>>(); +const liveRuns = new Map>(); + +async function publishLiveMessage(battleId: string, message: LiveMessage): Promise { + const subscribers = [...(liveSubscribers.get(battleId) ?? [])]; + await Promise.allSettled(subscribers.map((subscriber) => subscriber(message))); +} + +function startLiveBattle(battleId: string, idea: string): Promise { + const existing = liveRuns.get(battleId); + if (existing) return existing; + const run = (async () => { + try { + for await (const event of runLiveBattleFromPayload({ battleId, idea })) { + await liveBattleStore.append(battleId, event); + await publishLiveMessage(battleId, { type: "battle", event }); + } + await liveBattleStore.finish(battleId, "completed"); + await publishLiveMessage(battleId, { type: "done" }); + } catch (error) { + const message = error instanceof Error ? error.message : "实时战斗异常中断"; + await liveBattleStore.finish(battleId, "failed", message); + await publishLiveMessage(battleId, { type: "error", error: message }); + } finally { + liveRuns.delete(battleId); + } + })(); + liveRuns.set(battleId, run); + return run; +} app.get("/api/health", (context) => context.json({ status: "ok", service: "agent-arena-api" }), @@ -113,6 +144,18 @@ app.get("/api/battles/:id/events", async (context) => { return context.json({ battleId, source: "fixture", events: demoEvents() }); } + const localBattle = await liveBattleStore.get(battleId); + if (localBattle) { + return context.json({ battleId, source: "local-event-store", status: localBattle.status, error: localBattle.error, events: localBattle.events }); + } + + // Avoid loading the full Postgres adapter when persistence is not configured. + // The documented fallback is immediate and must not spend the request budget + // compiling database drivers only to discover DATABASE_URL is absent. + if (!process.env.DATABASE_URL) { + return context.json({ battleId, source: "fallback", events: [] }); + } + // Postgres/event-store integration is deliberately soft-failing: the replay // remains usable and never blocks the battle experience when storage is absent. try { @@ -179,10 +222,10 @@ app.post("/api/battles", async (context) => { return context.json({ error: "创意不能为空" }, 400); } - const ip = - context.req.header("cf-connecting-ip") ?? - context.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? - "anonymous"; + const trustProxyHeaders = process.env.TRUST_PROXY_HEADERS === "true"; + const ip = trustProxyHeaders + ? context.req.header("cf-connecting-ip") ?? context.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "proxied-client" + : "direct-client"; const decision = liveBattleRateLimiter.check(ip); if (!decision.allowed) { return context.json( @@ -193,7 +236,7 @@ app.post("/api/battles", async (context) => { } const battleId = `live_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; - liveBattlesInFlight.set(battleId, { idea, startedAt: Date.now() }); + await liveBattleStore.create(battleId, idea); return context.json({ battleId, sseUrl: `/api/battles/${battleId}/stream` }, 201); }); @@ -202,38 +245,73 @@ app.get("/api/battles/:id/stream", async (context) => { return context.json({ error: "实时 AI 竞技当前未开启" }, 501); } const battleId = context.req.param("id"); - const pending = liveBattlesInFlight.get(battleId); - if (!pending) { + const stored = await liveBattleStore.get(battleId); + if (!stored) { return context.json({ error: "找不到该战斗" }, 404); } return streamSSE(context, async (stream) => { - const heartbeatMs = Number.parseInt(process.env.SSE_HEARTBEAT_MS ?? "15000", 10); + const configuredHeartbeatMs = Number.parseInt(process.env.SSE_HEARTBEAT_MS ?? "2000", 10); + const heartbeatMs = Math.min(3000, Math.max(1000, Number.isFinite(configuredHeartbeatMs) ? configuredHeartbeatMs : 2000)); let closed = false; const heartbeat = setInterval(() => { if (closed) return; void stream.writeSSE({ event: "heartbeat", data: JSON.stringify({ at: Date.now() }) }); }, heartbeatMs); + const sentIds = new Set(); + let writeQueue = Promise.resolve(); + const write = (event: string, data: unknown): Promise => { + writeQueue = writeQueue.then(() => stream.writeSSE({ event, data: JSON.stringify(data) })); + return writeQueue; + }; + let resolveStream: () => void = () => undefined; + const completed = new Promise((resolve) => { resolveStream = resolve; }); + const subscriber = async (message: LiveMessage): Promise => { + if (message.type === "battle") { + if (sentIds.has(message.event.id)) return; + sentIds.add(message.event.id); + await write("battle", message.event); + return; + } + if (message.type === "done") await write("done", { battleId }); + else await write("error", { error: message.error }); + liveSubscribers.get(battleId)?.delete(subscriber); + resolveStream(); + }; + try { - for await (const event of runLiveBattleFromPayload({ battleId, idea: pending.idea })) { - await stream.writeSSE({ event: "battle", data: JSON.stringify(event) }); + const subscribers = liveSubscribers.get(battleId) ?? new Set(); + subscribers.add(subscriber); + liveSubscribers.set(battleId, subscribers); + + const latest = await liveBattleStore.get(battleId); + if (!latest) return; + for (const event of latest.events) { + if (sentIds.has(event.id)) continue; + sentIds.add(event.id); + await write("battle", event); + } + if (latest.status === "completed") { + await write("done", { battleId }); + return; + } + if (latest.status === "failed") { + await write("error", { error: latest.error ?? "实时战斗异常中断" }); + return; + } + if (latest.status === "running" && !liveRuns.has(battleId)) { + const interruption = "服务恢复后检测到未完成战斗;已保留现有证据,请重新发起以避免重复运行智能体。"; + await liveBattleStore.finish(battleId, "failed", interruption); + await write("error", { error: interruption }); + return; } - await stream.writeSSE({ event: "done", data: JSON.stringify({ battleId }) }); - } catch (err) { - const message = - err instanceof StepFunNotConfiguredError - ? err.message - : err instanceof LiveBattleIdeaTooLongError - ? err.message - : err instanceof Error - ? err.message - : "实时战斗异常中断"; - await stream.writeSSE({ event: "error", data: JSON.stringify({ error: message }) }); + void startLiveBattle(battleId, latest.idea); + await completed; } finally { + liveSubscribers.get(battleId)?.delete(subscriber); closed = true; clearInterval(heartbeat); - liveBattlesInFlight.delete(battleId); } }); }); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f639199..b4caca7 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,5 +1,24 @@ import { serve } from "@hono/node-server"; -import { app } from "./app"; +import { existsSync } from "node:fs"; +import path from "node:path"; + +const envCandidates: string[] = []; +let envDirectory = path.resolve("."); +while (true) { + envCandidates.push(path.join(envDirectory, ".env.local")); + const parent = path.dirname(envDirectory); + if (parent === envDirectory) break; + envDirectory = parent; +} + +for (const candidate of envCandidates) { + if (existsSync(candidate)) { + process.loadEnvFile(candidate); + break; + } +} + +const { app } = await import("./app"); serve({ fetch: app.fetch, port: 8787 }, (info) => { console.log(`Agent Arena API listening on http://localhost:${info.port}`); diff --git a/apps/api/src/live-battle-store.test.ts b/apps/api/src/live-battle-store.test.ts new file mode 100644 index 0000000..3bd6c65 --- /dev/null +++ b/apps/api/src/live-battle-store.test.ts @@ -0,0 +1,41 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { BattleEvent } from "@agent-arena/contracts"; +import { LocalLiveBattleStore, resolveLiveBattleRoot } from "./live-battle-store"; + +const event: BattleEvent = { + id: "event_1", battleId: "live_test", round: "briefing", eventType: "brief_created", + title: "简报下发", content: "测试", createdAt: "2026-07-25T00:00:00.000Z", +}; + +describe("LocalLiveBattleStore", () => { + it("resolves the default data directory from the API module, not process.cwd", () => { + const root = resolveLiveBattleRoot({} as NodeJS.ProcessEnv, "file:///C:/workspace/apps/api/src/live-battle-store.ts"); + expect(root.replaceAll("\\", "/")).toBe("C:/workspace/apps/api/.data/live-battles/"); + }); + it("persists battle events and terminal status across store instances", async () => { + const root = await mkdtemp(path.join(tmpdir(), "agent-arena-store-")); + const writer = new LocalLiveBattleStore(root); + await writer.create("live_test", "测试创意"); + await writer.append("live_test", event); + await writer.finish("live_test", "completed"); + + const reader = new LocalLiveBattleStore(root); + const restored = await reader.get("live_test"); + expect(restored).toMatchObject({ battleId: "live_test", idea: "测试创意", status: "completed" }); + expect(restored?.events).toHaveLength(1); + expect(restored?.events[0]).toMatchObject({ id: "event_1", sequence: 1 }); + expect(JSON.parse(await readFile(path.join(root, "live_test.json"), "utf8"))).toMatchObject({ status: "completed" }); + }); + + it("deduplicates repeated event ids", async () => { + const root = await mkdtemp(path.join(tmpdir(), "agent-arena-store-")); + const store = new LocalLiveBattleStore(root); + await store.create("live_test", "测试创意"); + await store.append("live_test", event); + await store.append("live_test", event); + expect((await store.get("live_test"))?.events).toHaveLength(1); + }); +}); diff --git a/apps/api/src/live-battle-store.ts b/apps/api/src/live-battle-store.ts new file mode 100644 index 0000000..ad0b00a --- /dev/null +++ b/apps/api/src/live-battle-store.ts @@ -0,0 +1,96 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { BattleEvent } from "@agent-arena/contracts"; +import { assertBattleEvent } from "@/arena/schemas/validators"; + +export type LiveBattleStatus = "created" | "running" | "completed" | "failed"; + +export type StoredLiveBattle = { + battleId: string; + idea: string; + status: LiveBattleStatus; + events: BattleEvent[]; + createdAt: string; + updatedAt: string; + error?: string; +}; + +export function resolveLiveBattleRoot(env: NodeJS.ProcessEnv = process.env, moduleUrl = import.meta.url): string { + if (env.VITEST) return path.join(tmpdir(), `agent-arena-live-${process.pid}`); + if (env.AGENT_ARENA_DATA_DIR) return path.resolve(env.AGENT_ARENA_DATA_DIR); + return fileURLToPath(new URL("../.data/live-battles/", moduleUrl)); +} + +export class LocalLiveBattleStore { + private readonly root: string; + private readonly queues = new Map>(); + + constructor(root = resolveLiveBattleRoot()) { this.root = root; } + + async create(battleId: string, idea: string): Promise { + const at = new Date().toISOString(); + const battle: StoredLiveBattle = { battleId, idea, status: "created", events: [], createdAt: at, updatedAt: at }; + await this.write(battle); + return battle; + } + + async get(battleId: string): Promise { + try { + return JSON.parse(await readFile(this.fileFor(battleId), "utf8")) as StoredLiveBattle; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + } + + async append(battleId: string, event: BattleEvent): Promise { + const validatedEvent = { + ...event, + actorType: event.actorId ? (event.actorId === "judge_panel" ? "judge" : "agent") : "system", + } as BattleEvent; + assertBattleEvent(validatedEvent); + await this.serial(battleId, async () => { + const battle = await this.get(battleId); + if (!battle) throw new Error(`找不到本地战斗 ${battleId}`); + if (battle.events.some((candidate) => candidate.id === event.id)) return; + battle.events.push({ ...validatedEvent, sequence: battle.events.length + 1 }); + battle.status = "running"; + battle.updatedAt = new Date().toISOString(); + await this.write(battle); + }); + } + + async finish(battleId: string, status: "completed" | "failed", error?: string): Promise { + await this.serial(battleId, async () => { + const battle = await this.get(battleId); + if (!battle) return; + battle.status = status; + battle.error = error; + battle.updatedAt = new Date().toISOString(); + await this.write(battle); + }); + } + + private fileFor(battleId: string): string { + return path.join(this.root, `${battleId.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`); + } + + private async write(battle: StoredLiveBattle): Promise { + await mkdir(this.root, { recursive: true }); + const target = this.fileFor(battle.battleId); + const temporary = `${target}.${process.pid}.tmp`; + await writeFile(temporary, JSON.stringify(battle, null, 2), "utf8"); + await rename(temporary, target); + } + + private async serial(battleId: string, operation: () => Promise): Promise { + const previous = this.queues.get(battleId) ?? Promise.resolve(); + const current = previous.then(operation, operation); + this.queues.set(battleId, current); + try { await current; } finally { + if (this.queues.get(battleId) === current) this.queues.delete(battleId); + } + } +} diff --git a/apps/api/src/middlewares/feature-flag.ts b/apps/api/src/middlewares/feature-flag.ts index 7f87be7..5f22af8 100644 --- a/apps/api/src/middlewares/feature-flag.ts +++ b/apps/api/src/middlewares/feature-flag.ts @@ -8,7 +8,7 @@ export function isLiveBattleEnabled(env: NodeJS.ProcessEnv = process.env): boolean { const value = env.AGENT_ARENA_LIVE_BATTLE_ENABLED; - if (typeof value !== "string") return false; + if (typeof value !== "string") return typeof env.STEPFUN_API_KEY === "string" && env.STEPFUN_API_KEY.trim().length > 0; const normalized = value.trim().toLowerCase(); return normalized === "true" || normalized === "1" || normalized === "yes"; } diff --git a/apps/web/src/App.live-evidence.test.ts b/apps/web/src/App.live-evidence.test.ts new file mode 100644 index 0000000..931f111 --- /dev/null +++ b/apps/web/src/App.live-evidence.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import type { BattleEvent } from "@agent-arena/contracts"; +import { buildLiveArtifactProjection, buildLiveEvidenceChain, buildLiveScoreDimensions, readLiveIdea } from "./App"; + +const base = { battleId: "live_test", createdAt: "2026-07-25T00:00:00.000Z" }; + +describe("live evidence projection", () => { + it("builds a team-specific chain from the current battle only", () => { + const events: BattleEvent[] = [ + { ...base, id: "proposal_live", round: "proposal_round", actorId: "agent_safe_builder_lead", eventType: "proposal_created", title: "学程卡提案", content: "本场提案", rawPayload: { teamId: "team_safe_v1" } }, + { ...base, id: "attack_live", round: "cross_attack_round", actorId: "agent_infra_hacker_lead", targetId: "team_safe_v1", eventType: "attack_created", title: "攻击 live_01", content: "本场攻击", rawPayload: { id: "live_01", attackerTeamId: "team_infra_v1", targetTeamId: "team_safe_v1" } }, + ]; + const chain = buildLiveEvidenceChain(events, "team_safe_v1"); + expect(chain.map((item) => item.eventId)).toEqual(["proposal_live", "attack_live"]); + expect(JSON.stringify(chain)).not.toContain("attack_031"); + expect(JSON.stringify(chain)).not.toContain("Safari"); + }); + + it("normalizes six live dimensions to an exact 100-point maximum", () => { + const events: BattleEvent[] = [{ + ...base, id: "score_live", round: "judging_round", actorId: "judge_panel", targetId: "team_safe_v1", + eventType: "score_created", title: "实时得分", content: "完成", + rawPayload: { teamId: "team_safe_v1", scores: { novelty: 10, feasibility: 10, demoWow: 10, technicalDepth: 10, userValue: 10, longTermPotential: 10 }, judgeComments: ["证据充分"] }, + }]; + const scores = buildLiveScoreDimensions(events, "team_safe_v1"); + expect(Object.values(scores ?? {}).reduce((sum, item) => sum + item.max, 0)).toBe(100); + expect(Object.values(scores ?? {}).reduce((sum, item) => sum + item.score, 0)).toBe(100); + }); + + it("restores the idea from the persisted brief event in a fresh browser", () => { + const events: BattleEvent[] = [{ + ...base, + id: "brief_live", + round: "briefing", + eventType: "brief_created", + title: "简报下发", + content: "用户创意:城市运动伙伴", + rawPayload: { idea: "城市运动伙伴" }, + }]; + expect(readLiveIdea(events, "live_test")).toBe("城市运动伙伴"); + }); + + it("projects a distinct proposal, revision, checks and evidence set for each team", () => { + const events: BattleEvent[] = [ + { ...base, id: "safe_proposal", round: "proposal_round", actorId: "agent_safe_builder_lead", eventType: "proposal_created", title: "稳行提案", content: "可靠路线", rawPayload: { teamId: "team_safe_v1", productName: "稳行", oneLiner: "可靠路线", technicalHighlight: "离线兜底" } }, + { ...base, id: "viral_proposal", round: "proposal_round", actorId: "agent_viral_designer_lead", eventType: "proposal_created", title: "绿途提案", content: "传播路线", rawPayload: { teamId: "team_viral_v1", productName: "绿途", oneLiner: "传播路线", technicalHighlight: "分享卡" } }, + { ...base, id: "safe_defense", round: "defense_round", actorId: "agent_safe_builder_lead", eventType: "defense_created", title: "稳行防守", content: "增加离线缓存", rawPayload: { teamId: "team_safe_v1", attackId: "attack_safe", acceptedAttack: true, revision: "增加离线缓存" } }, + { ...base, id: "viral_defense", round: "defense_round", actorId: "agent_viral_designer_lead", eventType: "defense_created", title: "绿途防守", content: "补充分享卡", rawPayload: { teamId: "team_viral_v1", targetTeamId: "team_safe_v1", attackId: "attack_viral", acceptedAttack: true, revision: "补充分享卡" } }, + { ...base, id: "viral_artifact", round: "artifact_generation", actorId: "artifact_writer", targetId: "team_viral_v1", eventType: "artifact_created", title: "绿途 产品简报", content: "冠军最终作品", rawPayload: { teamId: "team_viral_v1", sourceEventIds: ["viral_proposal", "viral_defense"] } }, + ]; + + const safe = buildLiveArtifactProjection(events, "team_safe_v1", "team_viral_v1"); + const viral = buildLiveArtifactProjection(events, "team_viral_v1", "team_viral_v1"); + expect(safe?.isChampion).toBe(false); + expect(safe?.title).toContain("稳行"); + expect(safe?.v2Content).toContain("增加离线缓存"); + expect(safe?.v2Content).not.toContain("补充分享卡"); + expect(safe?.patch).not.toContain("补充分享卡"); + expect(safe?.evidence.map((item) => item.eventId)).toEqual(["safe_proposal", "safe_defense", "viral_defense"]); + expect(viral?.isChampion).toBe(true); + expect(viral?.title).toBe("绿途 产品简报"); + expect(viral?.v2Content).toContain("冠军最终作品"); + expect(viral?.evidence.map((item) => item.eventId)).toEqual(["viral_proposal", "viral_defense", "viral_artifact"]); + expect(safe?.v2Content).not.toBe(viral?.v2Content); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 86beb50..7ea67fd 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,12 +1,13 @@ import { Link, NavLink, Navigate, Route, Routes, useParams, useSearchParams } from "react-router-dom"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { BattleEvent, TeamPassport } from "@agent-arena/contracts"; -import { BattleWorkspace } from "./components/BattleWorkspace"; -import { BattleArchive } from "./components/BattleArchive"; -import { AgentPassport } from "./components/AgentPassport"; import { HomeExperience } from "./components/HomeExperience"; -import { LiveArenaPage } from "./components/live-arena-page"; +import { LiveArenaPage, V052_TEAMS } from "./components/live-arena-page"; import { ChampionPage } from "./components/champion-page"; +import { ArtifactModal } from "./components/artifact-modal"; +import { EvidenceLensModal } from "./components/evidence-lens-modal"; +import { ArtifactWorkspace, EvidenceLinks, MiniAppDemo, PatchDiff, TestResultsTable, VersionCompare } from "./components/artifact-tabs"; +import { LiveAiDegraded } from "./components/live-ai-degraded"; import { loadBattleEvents } from "./data/battle"; import { t } from "./i18n"; import type { RuntimeMode } from "./components/runtime-mode-badge"; @@ -15,13 +16,13 @@ function Shell({ children }: { children: React.ReactNode }) { return (
- AGENT ARENAWHERE AI AGENTS PROVE THEMSELVES -
{children}
@@ -37,113 +38,456 @@ function BattlePage() { const [searchParams] = useSearchParams(); const mode = (searchParams.get("mode") ?? "verified_replay") as RuntimeMode; - if (battleId === "BA-2026-0024" || mode !== "verified_replay") { - return ; - } - return
; + return ; } -function LiveArenaRoute({ battleId, mode }: { battleId: string; mode: RuntimeMode }) { +function LiveArenaRoute({ battleId, mode, forceFatal, completeReplay, replayRequested }: { battleId: string; mode: RuntimeMode; forceFatal: boolean; completeReplay: boolean; replayRequested: boolean }) { const [events, setEvents] = useState(null); + const [liveFailed, setLiveFailed] = useState(false); + const [elapsedMs, setElapsedMs] = useState(0); + const [artifactTeamId, setArtifactTeamId] = useState(null); + const [evidenceTeamId, setEvidenceTeamId] = useState(null); + const [replayCursor, setReplayCursor] = useState(0); + const [replayPaused, setReplayPaused] = useState(false); + const [heartbeatCount, setHeartbeatCount] = useState(0); + const [liveComplete, setLiveComplete] = useState(false); + const liveStartedAtRef = useRef(Date.now()); + const liveLatestAtRef = useRef(null); useEffect(() => { let cancelled = false; + if (mode === "live_runtime" && !replayRequested) { + const startedAt = Date.now(); + liveStartedAtRef.current = startedAt; + liveLatestAtRef.current = null; + setEvents([]); + setLiveFailed(false); + setHeartbeatCount(0); + setLiveComplete(false); + const elapsedTimer = window.setInterval(() => setElapsedMs(Date.now() - liveStartedAtRef.current), 250); + const source = new EventSource(`/api/battles/${encodeURIComponent(battleId)}/stream`); + const firstEventTimer = window.setTimeout(() => { + if (!cancelled) { + setLiveFailed(true); + source.close(); + } + }, 10_000); + source.addEventListener("battle", (message) => { + if (cancelled) return; + try { + const event = JSON.parse((message as MessageEvent).data) as BattleEvent; + if (!event.id || !event.eventType || !event.round) throw new Error("Invalid live event"); + const eventTime = Date.parse(event.createdAt); + if (Number.isFinite(eventTime)) { + liveStartedAtRef.current = Math.min(liveStartedAtRef.current, eventTime); + liveLatestAtRef.current = Math.max(liveLatestAtRef.current ?? eventTime, eventTime); + } + window.clearTimeout(firstEventTimer); + setEvents((current) => current?.some((candidate) => candidate.id === event.id) ? current : [...(current ?? []), event]); + } catch { + setLiveFailed(true); + source.close(); + } + }); + source.addEventListener("heartbeat", () => { + if (!cancelled) setHeartbeatCount((count) => count + 1); + }); + source.addEventListener("done", () => { + if (cancelled) return; + source.close(); + window.clearInterval(elapsedTimer); + setElapsedMs(Math.max(0, (liveLatestAtRef.current ?? Date.now()) - liveStartedAtRef.current)); + setLiveComplete(true); + }); + source.addEventListener("error", () => { + if (cancelled) return; + window.clearInterval(elapsedTimer); + setLiveFailed(true); + source.close(); + }); + return () => { + cancelled = true; + source.close(); + window.clearInterval(elapsedTimer); + window.clearTimeout(firstEventTimer); + }; + } void loadBattleEvents(battleId).then((result) => { - if (!cancelled) setEvents(result.events); + if (!cancelled) { + setEvents(result.events); + setReplayCursor(completeReplay ? result.events.length : 0); + setReplayPaused(false); + } }); return () => { cancelled = true; }; - }, [battleId]); + }, [battleId, mode, completeReplay, replayRequested]); + + useEffect(() => { + const playbackActive = mode === "verified_replay" || replayRequested; + if (!playbackActive || completeReplay || replayPaused || !events || replayCursor >= events.length) return; + const visibleEvent = replayCursor > 0 ? events[replayCursor - 1] : undefined; + const payload = visibleEvent?.rawPayload as { id?: string; severity?: string } | undefined; + const delay = replayCursor === 0 + ? 250 + : visibleEvent?.eventType === "attack_created" && payload?.severity === "fatal" + ? 3200 + : visibleEvent?.eventType === "attack_created" + ? 1500 + : visibleEvent?.eventType === "defense_created" + ? 1350 + : visibleEvent?.eventType === "score_created" + ? 1200 + : visibleEvent?.eventType === "champion_selected" + ? 1200 + : 850; + const timer = window.setTimeout(() => setReplayCursor((cursor) => Math.min(cursor + 1, events.length)), delay); + return () => window.clearTimeout(timer); + }, [completeReplay, events, mode, replayCursor, replayPaused, replayRequested]); if (!events) { return

{t("common.loading")}…

; } + const returnToVerified = () => { + window.location.href = `/battle/BA-2026-0024?mode=verified_replay`; + }; + if (mode === "demo_fallback" || liveFailed) { + return ; + } + + const playbackActive = mode === "verified_replay" || replayRequested; + const replayEvents = playbackActive && !forceFatal ? events.slice(0, replayCursor) : events; + const visibleEvents = forceFatal + ? events.slice(0, Math.max(0, events.findIndex((event) => (event.rawPayload as { id?: string } | undefined)?.id === "defense_041")) + 1) + : replayEvents; + const liveWinnerTeamId = mode === "live_runtime" + ? (visibleEvents.find((event) => event.eventType === "champion_selected")?.rawPayload as { winnerTeamId?: string } | undefined)?.winnerTeamId + : undefined; + const selectedArtifactTeam = V052_TEAMS.find((team) => team.id === artifactTeamId); + const selectedEvidenceTeam = V052_TEAMS.find((team) => team.id === evidenceTeamId); + const artifactProjection = artifactTeamId + ? buildLiveArtifactProjection(visibleEvents, artifactTeamId, mode === "live_runtime" ? liveWinnerTeamId : "team_viral_v1") + : null; + const goldenChampionArtifactVisible = visibleEvents.some((event) => event.eventType === "artifact_created" && event.round === "artifact_generation"); + const selectedGoldenChampion = mode === "verified_replay" && artifactTeamId === "team_viral_v1" && goldenChampionArtifactVisible; + const selectedEvidenceChain = evidenceTeamId ? buildLiveEvidenceChain(visibleEvents, evidenceTeamId) : []; + const liveEvidenceScores = mode === "live_runtime" && evidenceTeamId ? buildLiveScoreDimensions(visibleEvents, evidenceTeamId) : undefined; + const goldenScoreVisible = visibleEvents.some((event) => event.eventType === "score_created"); + const evidenceCompleteness = mode === "live_runtime" + ? liveEvidenceScores && selectedEvidenceChain.length > 0 ? "full_breakdown" : selectedEvidenceChain.length > 0 ? "linked_evidence" : "insufficient_evidence" + : evidenceTeamId === "team_viral_v1" && goldenScoreVisible + ? "full_breakdown" + : selectedEvidenceChain.length > 0 + ? "linked_evidence" + : "insufficient_evidence"; + return ( - + <> + 0 && replayCursor >= events.length : liveComplete} + replayPlayback={playbackActive} + championAvailable={visibleEvents.some((event) => event.eventType === "champion_selected")} + replayProgress={{ current: replayCursor, total: events.length }} + onToggleReplay={() => setReplayPaused((paused) => !paused)} + onRestartReplay={() => { setReplayCursor(0); setReplayPaused(false); }} + onViewChampion={() => { window.location.href = `/battle/${battleId}/champion?mode=${mode}`; }} + liveHeartbeatCount={heartbeatCount} + onOpenArtifact={setArtifactTeamId} + onOpenEvidenceLens={setEvidenceTeamId} + /> + setArtifactTeamId(null)} + teamName={selectedArtifactTeam?.name ?? t("app.team.viral")} + versionsContent={mode === "live_runtime" || !selectedGoldenChampion + ? artifactProjection + ? <> + + + + :

{t("arena.artifact.pending")}

+ : } + patchContent={} + testsContent={} + evidenceContent={} + /> + setEvidenceTeamId(null)} + teamName={selectedEvidenceTeam?.name ?? t("app.team.viral")} + accentColor={selectedEvidenceTeam?.accentColor ?? "var(--team-viral)"} + completeness={evidenceCompleteness} + scores={evidenceCompleteness !== "full_breakdown" ? undefined : mode === "live_runtime" ? liveEvidenceScores : GOLDEN_CHAMPION.scores} + evidenceChain={evidenceCompleteness === "insufficient_evidence" ? [] : selectedEvidenceChain} + onOpenArtifact={() => { + const teamId = evidenceTeamId; + setEvidenceTeamId(null); + setArtifactTeamId(teamId); + }} + /> + ); } +export function readLiveIdea(events: readonly BattleEvent[], battleId: string): string { + const persistedIdea = (events.find((event) => event.eventType === "brief_created")?.rawPayload as { idea?: string } | undefined)?.idea; + if (persistedIdea?.trim()) return persistedIdea.trim(); + try { + return window.sessionStorage.getItem(`agent-arena:idea:${battleId}`) ?? t("app.live.default_idea"); + } catch { + return t("app.live.default_idea"); + } +} + +function eventBelongsToTeam(event: BattleEvent, teamId: string): boolean { + const payload = event.rawPayload as { teamId?: string; attackerTeamId?: string; targetTeamId?: string; winnerTeamId?: string } | undefined; + const actorTeamId: Record = { + agent_safe_builder_lead: "team_safe_v1", + agent_viral_designer_lead: "team_viral_v1", + agent_infra_hacker_lead: "team_infra_v1", + }; + return event.actorId === teamId + || actorTeamId[event.actorId ?? ""] === teamId + || event.targetId === teamId + || payload?.teamId === teamId + || payload?.attackerTeamId === teamId + || payload?.targetTeamId === teamId + || payload?.winnerTeamId === teamId + || (event.eventType === "score_created" && !event.targetId && !payload?.teamId); +} + +function eventProducedByTeam(event: BattleEvent, teamId: string): boolean { + const payload = event.rawPayload as { teamId?: string } | undefined; + const actorTeamId: Record = { + agent_safe_builder_lead: "team_safe_v1", + agent_viral_designer_lead: "team_viral_v1", + agent_infra_hacker_lead: "team_infra_v1", + }; + return payload?.teamId === teamId || event.actorId === teamId || actorTeamId[event.actorId ?? ""] === teamId; +} + +export function buildLiveEvidenceChain(events: readonly BattleEvent[], teamId: string) { + const typeLabel: Partial> = { + brief_created: t("app.event.brief"), team_created: t("app.event.team"), proposal_created: t("app.event.proposal"), attack_created: t("app.event.attack"), + defense_created: t("app.event.defense"), score_created: t("app.event.score"), champion_selected: t("app.event.champion"), artifact_created: t("app.event.artifact"), + passport_created: t("app.event.passport"), replay_created: t("app.event.replay"), error: t("app.event.error"), + }; + return events + .filter((event) => event.eventType !== "commentary_created" && eventBelongsToTeam(event, teamId)) + .map((event) => ({ eventId: event.id, label: `${event.title} · ${typeLabel[event.eventType] ?? t("app.event.generic")}` })) + .slice(-8); +} + +function buildLivePatch(events: readonly BattleEvent[], teamId: string): string { + const defenses = events.filter((event) => event.eventType === "defense_created" && eventProducedByTeam(event, teamId)); + if (defenses.length === 0) return `${t("app.patch.header")}\n ${t("app.patch.empty")}`; + return [t("app.patch.ai_header"), ...defenses.map((event) => { + const payload = event.rawPayload as { acceptedAttack?: boolean; revision?: string; attackId?: string } | undefined; + return `${payload?.acceptedAttack ? "+" : " "} ${payload?.attackId ?? event.id}:${payload?.revision ?? event.content}`; + })].join("\n"); +} + +function buildLiveEvidenceChecks(events: readonly BattleEvent[], teamId: string) { + const hasProposal = events.some((event) => event.eventType === "proposal_created" && eventProducedByTeam(event, teamId)); + const hasScore = events.some((event) => event.eventType === "score_created" && (event.targetId === teamId || (!event.targetId && !(event.rawPayload as { teamId?: string } | undefined)?.teamId))); + const hasArtifact = events.some((event) => event.eventType === "artifact_created" && eventProducedByTeam(event, teamId)); + return [ + { id: "check_proposal", name: t("app.check.proposal"), input: t("app.check.event_store"), expected: t("app.check.proposal_expected"), actual: hasProposal ? t("app.check.linked") : t("app.check.unlinked"), passed: hasProposal }, + { id: "check_score", name: t("app.check.score"), input: t("app.check.event_store"), expected: t("app.check.score_expected"), actual: hasScore ? t("app.check.linked") : t("app.check.unlinked"), passed: hasScore }, + { id: "check_artifact", name: t("app.check.artifact"), input: t("app.check.event_store"), expected: t("app.check.artifact_expected"), actual: hasArtifact ? t("app.check.linked") : t("app.check.unlinked"), passed: hasArtifact }, + ]; +} + +export type LiveArtifactProjection = { + teamId: string; + isChampion: boolean; + title: string; + summary: string; + v1Content: string; + v2Content: string; + patch: string; + tests: ReturnType; + evidence: ReturnType; +}; + +export function buildLiveArtifactProjection(events: readonly BattleEvent[], teamId: string, winnerTeamId?: string): LiveArtifactProjection | null { + const proposalEvent = events.find((event) => event.eventType === "proposal_created" && eventProducedByTeam(event, teamId)); + if (!proposalEvent) return null; + const defenseEvent = [...events].reverse().find((event) => event.eventType === "defense_created" && eventProducedByTeam(event, teamId)); + const scoreEvent = [...events].reverse().find((event) => event.eventType === "score_created" && event.targetId === teamId); + const artifactEvent = [...events].reverse().find((event) => event.eventType === "artifact_created" && eventProducedByTeam(event, teamId)); + const proposal = proposalEvent.rawPayload as { productName?: string; oneLiner?: string; technicalHighlight?: string; demoPlan?: string } | undefined; + const defense = defenseEvent?.rawPayload as { acceptedAttack?: boolean; revision?: string; responseToAttack?: string } | undefined; + const productName = proposal?.productName ?? proposalEvent.title.replace(/\s*提案$/, ""); + const proposalSummary = proposal?.oneLiner ?? proposalEvent.content; + const revision = defense?.revision ?? defense?.responseToAttack ?? t("artifact.team.no_revision"); + const scoreSummary = scoreEvent?.content ?? t("artifact.team.no_score"); + const isChampion = winnerTeamId === teamId; + const title = artifactEvent?.title ?? `${productName} · ${t("artifact.team.snapshot")}`; + const summary = artifactEvent?.content ?? `${proposalSummary} · ${revision}`; + const v1Content = [ + `${t("artifact.team.product")}:${productName}`, + `${t("artifact.team.proposal")}:${proposalSummary}`, + `${t("artifact.team.technical")}:${proposal?.technicalHighlight ?? t("artifact.team.not_recorded")}`, + `${t("artifact.team.demo")}:${proposal?.demoPlan ?? t("artifact.team.not_recorded")}`, + ].join("\n"); + const v2Content = [ + `${t("artifact.team.product")}:${artifactEvent?.title ?? productName}`, + `${t("artifact.team.revision")}:${revision}`, + `${t("artifact.team.judge")}:${scoreSummary}`, + `${t("artifact.team.delivery")}:${artifactEvent ? t("artifact.team.champion_delivered") : t("artifact.team.proposal_snapshot")}`, + ].join("\n"); + return { + teamId, + isChampion, + title, + summary, + v1Content, + v2Content, + patch: buildLivePatch(events, teamId), + tests: buildLiveEvidenceChecks(events, teamId), + evidence: buildLiveEvidenceChain(events, teamId), + }; +} + +export function buildLiveScoreDimensions(events: readonly BattleEvent[], teamId: string): TeamPassport["scores"] | undefined { + const scoreEvent = events.find((event) => event.eventType === "score_created" && event.targetId === teamId); + const rawScores = (scoreEvent?.rawPayload as { scores?: Record } | undefined)?.scores; + if (!scoreEvent || !rawScores) return undefined; + const scaled = (key: string, multiplier: number, max: number) => Math.min(max, Math.round((rawScores[key] ?? 0) * multiplier)); + const dimension = (key: string, multiplier: number, max: number) => ({ + score: scaled(key, multiplier, max), max, completeness: "full_breakdown" as const, + breakdown: [{ label: t("app.score.judge"), delta: scaled(key, multiplier, max), evidenceEventIds: [scoreEvent.id] }], + }); + return { + feasibility_zh: dimension("feasibility", 2.5, 25), + originality: dimension("novelty", 2, 20), + demoPower: dimension("demoWow", 2, 20), + technicalDepth: dimension("technicalDepth", 1.5, 15), + clarity: dimension("userValue", 1.5, 15), + riskControl: dimension("longTermPotential", 0.5, 5), + }; +} + +const ARTIFACT_V1 = `function renderShareCard(canvas) {\n return canvas.toDataURL("image/png");\n}`; +const ARTIFACT_V2 = `function renderShareCard(canvas) {\n if (isLegacySafari()) return renderSvgCard();\n return canvas.toDataURL("image/png");\n}`; +const ARTIFACT_PATCH = `@@ share-card.ts @@\n- return canvas.toDataURL("image/png");\n+ if (isLegacySafari()) return renderSvgCard();\n+ return canvas.toDataURL("image/png");`; +const ARTIFACT_TESTS = [ + { id: "test_022", name: t("app.golden.test.canvas"), input: "Safari 16.4", expected: t("app.golden.test.share_expected"), actual: "SecurityError", passed: false }, + { id: "test_032", name: t("app.golden.test.modern"), input: "Chrome / Safari 17", expected: t("app.golden.test.share_expected"), actual: t("app.golden.test.success"), passed: true }, + { id: "test_052", name: t("app.golden.test.svg"), input: "Safari 16.4", expected: t("app.golden.test.svg_expected"), actual: t("app.golden.test.fallback_pass"), passed: true }, +] as const; +const ARTIFACT_EVIDENCE = [ + { eventId: "evt_008", label: `attack_031 · Safari 16.4 ${t("app.golden.evidence.fatal")}` }, + { eventId: "evt_011", label: `defense_041 · ${t("app.golden.evidence.defense")}` }, + { eventId: "evt_013", label: `patch_048 · SVG ${t("app.golden.evidence.patch")}` }, + { eventId: "evt_016", label: `test_052 · ${t("app.golden.evidence.regression")}` }, +] as const; + // Champion passport for the golden BA-2026-0024 storyline. // Write-locked per docs/DEV-STANDARDS.md §8. const GOLDEN_CHAMPION: TeamPassport = { teamId: "team_viral_v1", - teamName: "传播设计师", + teamName: t("app.team.viral"), accentColor: "var(--team-viral)", totalScore: 87, scores: { feasibility_zh: { score: 23, max: 25, completeness: "full_breakdown", breakdown: [ - { label: "技术栈成熟", delta: 25 }, - { label: "分享链路集成复杂", delta: -2 }, + { label: t("app.golden.score.stack"), delta: 25 }, + { label: t("app.golden.score.share_complex"), delta: -2 }, ], }, originality: { - score: 20, max: 25, completeness: "full_breakdown", + score: 20, max: 20, completeness: "full_breakdown", breakdown: [ - { label: "游戏化玩法新", delta: 22 }, - { label: "竞品参考较多", delta: -2 }, + { label: t("app.golden.score.gameplay"), delta: 22 }, + { label: t("app.golden.score.competition"), delta: -2 }, ], }, demoPower: { - score: 19, max: 25, completeness: "full_breakdown", + score: 19, max: 20, completeness: "full_breakdown", breakdown: [ - { label: "演示流畅性强", delta: 22 }, - { label: "题目覆盖面偏窄", delta: -3 }, + { label: t("app.golden.score.demo"), delta: 22 }, + { label: t("app.golden.score.coverage"), delta: -3 }, ], }, technicalDepth: { score: 13, max: 15, completeness: "linked_evidence", - breakdown: [{ label: "SVG 降级工程完整", delta: 13 }], + breakdown: [{ label: t("app.golden.score.svg"), delta: 13 }], }, clarity: { - score: 8, max: 10, completeness: "linked_evidence", - breakdown: [{ label: "叙事清晰", delta: 8 }], + score: 8, max: 15, completeness: "linked_evidence", + breakdown: [{ label: t("app.golden.score.narrative"), delta: 8 }], }, riskControl: { score: 4, max: 5, completeness: "linked_evidence", - breakdown: [{ label: "fatal 修复及时", delta: 4 }], + breakdown: [{ label: t("app.golden.score.fatal"), delta: 4 }], }, }, - strengths: ["演示力最强", "fatal 攻击下恢复力被验证", "传播路径清晰"], - weaknesses: ["题目技术深度一般", "题库规模偏小"], - improvementSuggestions: ["补强测试覆盖", "接入向量检索提升题目多样性"], + strengths: [t("app.golden.strength.demo"), t("app.golden.strength.recovery"), t("app.golden.strength.share")], + weaknesses: [t("app.golden.weakness.depth"), t("app.golden.weakness.bank")], + improvementSuggestions: [t("app.golden.improve.tests"), t("app.golden.improve.retrieval")], journey: [ - { round: "proposal", eventId: "evt_004", title: "ClashQuiz 提案" }, - { round: "attack", eventId: "evt_008", title: "致命攻击 attack_031" }, - { round: "defense", eventId: "evt_011", title: "防守 defense_041" }, - { round: "patch", eventId: "evt_013", title: "patch_048 降级 SVG" }, - { round: "verify", eventId: "evt_016", title: "test_052 通过" }, - { round: "judging", eventId: "evt_018", title: "冠军 87/100" }, + { round: "proposal", eventId: "evt_004", title: `ClashQuiz ${t("app.event.proposal")}` }, + { round: "attack", eventId: "evt_008", title: `${t("app.golden.evidence.fatal")} attack_031` }, + { round: "defense", eventId: "evt_011", title: `${t("app.event.defense")} defense_041` }, + { round: "patch", eventId: "evt_013", title: `patch_048 ${t("app.golden.evidence.patch")} SVG` }, + { round: "verify", eventId: "evt_016", title: `test_052 ${t("app.golden.test.pass")}` }, + { round: "judging", eventId: "evt_018", title: `${t("app.event.champion")} 87/100` }, ], evidenceCompleteness: "full_breakdown", }; const GOLDEN_OTHER_TEAMS = [ - { name: "稳健构建者", score: 78, accentColor: "var(--team-safe)" }, - { name: "架构黑客", score: 84, accentColor: "var(--team-infra)" }, + { name: t("app.team.safe"), score: 78, accentColor: "var(--team-safe)" }, + { name: t("app.team.infra"), score: 84, accentColor: "var(--team-infra)" }, ]; function ChampionRoute() { const { battleId = "demo" } = useParams(); const [searchParams] = useSearchParams(); const mode = (searchParams.get("mode") ?? "verified_replay") as RuntimeMode; - const liveIncomplete = mode === "live_runtime"; + if (mode === "live_runtime") return ; + if (battleId !== "BA-2026-0024") { + return { window.location.href = `/battle/${battleId}?mode=verified_replay&replay=1`; }} + onWatchVerified={() => { window.location.href = "/battle/BA-2026-0024?mode=verified_replay"; }} + onBackHome={() => { window.location.href = "/"; }} + />; + } return ( { window.location.href = `/battle/${battleId}?mode=${mode}`; }} + liveIncomplete={false} + onBackToArena={() => { window.location.href = `/battle/${battleId}?mode=${mode}&replay=1`; }} onWatchVerified={() => { window.location.href = `/battle/${battleId}?mode=verified_replay`; }} + onBackHome={() => { window.location.href = "/"; }} onShare={() => { void navigator.clipboard?.writeText(window.location.href); }} @@ -151,13 +495,46 @@ function ChampionRoute() { ); } -function BattlesPage() { - return ; +function buildLivePassport(events: readonly BattleEvent[]): { passport: TeamPassport; others: Array<{ name: string; score: number; accentColor: string }> } | null { + const championEvent = [...events].reverse().find((event) => event.eventType === "champion_selected"); + const winnerTeamId = (championEvent?.rawPayload as { winnerTeamId?: string } | undefined)?.winnerTeamId; + if (!winnerTeamId) return null; + const winner = V052_TEAMS.find((team) => team.id === winnerTeamId); + if (!winner) return null; + const scoreEvents = events.filter((event) => event.eventType === "score_created"); + const scoreEvent = scoreEvents.find((event) => event.targetId === winnerTeamId); + const dimensions = buildLiveScoreDimensions(events, winnerTeamId); + if (!dimensions) return null; + const totalScore = Object.values(dimensions).reduce((sum, dimension) => sum + dimension.score, 0); + const comments = (scoreEvent?.rawPayload as { judgeComments?: string[] } | undefined)?.judgeComments ?? []; + const journey = events.filter((event) => { + if (event.eventType === "proposal_created" || event.eventType === "defense_created" || event.eventType === "artifact_created") { + return eventProducedByTeam(event, winnerTeamId); + } + if (event.eventType === "attack_created") return eventBelongsToTeam(event, winnerTeamId); + if (event.eventType === "champion_selected") return eventBelongsToTeam(event, winnerTeamId); + return false; + }).slice(-6).map((event) => ({ round: event.round, eventId: event.id, title: event.title })); + const passport: TeamPassport = { + teamId: winnerTeamId, teamName: winner.name, accentColor: winner.accentColor, totalScore, scores: dimensions, + strengths: comments.length > 0 ? comments.slice(0, 3) : [t("app.live.strength")], + weaknesses: [t("app.live.weakness")], + improvementSuggestions: [t("app.live.improvement")], journey, evidenceCompleteness: "full_breakdown", + }; + const others = V052_TEAMS.filter((team) => team.id !== winnerTeamId).map((team) => { + const event = scoreEvents.find((candidate) => candidate.targetId === team.id); + const values = Object.values((event?.rawPayload as { scores?: Record } | undefined)?.scores ?? {}); + return { name: team.name, score: Math.round(values.reduce((sum, value) => sum + value, 0) / 60 * 100), accentColor: team.accentColor }; + }); + return { passport, others }; } -function PassportPage() { - const { agentId } = useParams(); - return ; +function LiveChampionRoute({ battleId }: { battleId: string }) { + const [result, setResult] = useState | undefined>(undefined); + useEffect(() => { void loadBattleEvents(battleId).then(({ events }) => setResult(buildLivePassport(events))); }, [battleId]); + if (result === undefined) return

{t("common.loading")}…

; + if (result === null) return { window.location.href = `/battle/${battleId}?mode=live_runtime&replay=1`; }} onWatchVerified={() => { window.location.href = "/battle/BA-2026-0024?mode=verified_replay"; }} onBackHome={() => { window.location.href = "/"; }} />; + return { window.location.href = `/battle/${battleId}?mode=live_runtime&replay=1`; }} onBackHome={() => { window.location.href = "/"; }} onShare={() => { void navigator.clipboard?.writeText(window.location.href); }} />; } export function App() { @@ -167,8 +544,6 @@ export function App() { } /> } /> } /> - } /> - } /> } /> diff --git a/apps/web/src/components/ArenaStage.tsx b/apps/web/src/components/ArenaStage.tsx index 09e5c0e..ab483cf 100644 --- a/apps/web/src/components/ArenaStage.tsx +++ b/apps/web/src/components/ArenaStage.tsx @@ -2,13 +2,14 @@ import { DAMAGE_MAP, type BattleEvent, type Severity } from "@agent-arena/contra import { demoEvents, teams } from "../data/demo"; import { useArenaState, useBattleReplay } from "./BattleReplayPlayer"; import { useEffect } from "react"; +import { t, type ZhKey } from "../i18n"; -const roundNames: Record = { - proposal_round: "PROPOSAL ROUND", - cross_attack_round: "ATTACK ROUND", - defense_round: "DEFENSE ROUND", - scoring_round: "SCORING ROUND", - champion_round: "CHAMPION REVEAL", +const roundNames: Record = { + proposal_round: "arena.legacy.proposal", + cross_attack_round: "arena.legacy.attack", + defense_round: "arena.legacy.defense", + scoring_round: "arena.legacy.scoring", + champion_round: "arena.legacy.champion", }; export function ArenaStage({ compact = false, battleId = "demo", events = demoEvents, onProgress }: { compact?: boolean; battleId?: string; events?: readonly BattleEvent[]; onProgress?: (events: readonly BattleEvent[]) => void }) { @@ -18,14 +19,14 @@ export function ArenaStage({ compact = false, battleId = "demo", events = demoEv useEffect(() => onProgress?.(replay.visibleEvents), [onProgress, replay.visibleEvents]); const activeActors = new Set(replay.batch?.events.map((event) => event.actorId)); const roundTitle = replay.batch?.events.some((event) => event.eventType === "champion_selected") - ? "CHAMPION REVEAL" + ? t("arena.legacy.champion") : replay.batch?.events.some((event) => event.eventType === "score_created") - ? "SCORING ROUND" - : roundNames[replay.batch?.round ?? "proposal_round"]; + ? t("arena.legacy.scoring") + : t(roundNames[replay.batch?.round ?? "proposal_round"] ?? "arena.legacy.proposal"); return ( -
-
LIVE BATTLEAGENT ARENABATTLE ID · {battleId.toUpperCase()}
ROUND {replay.batchIndex + 1} / {replay.batchCount}02:14
-
ROUND {replay.batchIndex + 1} / {replay.batchCount}

{roundTitle}

+
+
{t("arena.legacy.live_battle")}AGENT ARENA{t("arena.header.battle_label")} ID · {battleId.toUpperCase()}
{t("arena.legacy.round")} {replay.batchIndex + 1} / {replay.batchCount}02:14
+
{t("arena.legacy.round")} {replay.batchIndex + 1} / {replay.batchCount}

{roundTitle}

{teams.map((team) => { const event = latestByActor.get(team.id); @@ -36,23 +37,23 @@ export function ArenaStage({ compact = false, battleId = "demo", events = demoEv const severity = (linkedAttack?.rawPayload as { severity?: Severity } | undefined)?.severity; const hit = Boolean(defensePayload?.acceptedAttack); const damage = hit && severity ? DAMAGE_MAP[severity] : 0; - return
{damage > 0 && -{damage}}
{`${team.name}
{team.name}{team.role}
HP{hp[team.id] ?? 100}/100
{event?.eventType.replace("_created", "").toUpperCase() ?? "STANDING BY"}{defensePayload && {hit ? "ACCEPTED" : "REJECTED"}}

{event?.content ?? "Waiting for the round signal…"}

{active ? "Typing_" : "Evidence locked"}
; + return
{damage > 0 && -{damage}}
{team.name}{team.role}
{t("arena.agent_card.proof_label")}{hp[team.id] ?? 100}/100
{roundTitle}{defensePayload && {hit ? t("arena.legacy.accepted") : t("arena.legacy.rejected")}}

{event?.content ?? t("arena.legacy.waiting")}

{active ? t("arena.legacy.typing") : t("arena.legacy.evidence_locked")}
; })}
-
● LIVE COMMENTARY{commentaryFor(replay.batch?.events ?? [])}
-
- {!compact &&
{Array.from({length: replay.batchCount},(_,index) => )}
} +
● {t("arena.legacy.commentary")}{commentaryFor(replay.batch?.events ?? [])}
+
+ {!compact &&
{Array.from({length: replay.batchCount},(_,index) => )}
}
); } function commentaryFor(events: readonly BattleEvent[]) { const event = events[events.length - 1]; - if (!event) return "Arena systems standing by."; - if (event.eventType === "proposal_created") return "Three teams are writing at once. Every claim will become replayable evidence."; - if (event.eventType === "attack_created") return `${event.title}. The target now has to answer the evidence.`; + if (!event) return t("arena.legacy.commentary.standby"); + if (event.eventType === "proposal_created") return t("arena.legacy.commentary.proposal"); + if (event.eventType === "attack_created") return `${event.title}。${t("arena.legacy.commentary.attack")}`; if (event.eventType === "defense_created") return event.content; - if (event.eventType === "score_created") return "Judges are binding scores to the recorded evidence chain."; - if (event.eventType === "champion_selected") return `${event.title}. Reputation updated from battle evidence.`; + if (event.eventType === "score_created") return t("arena.legacy.commentary.scoring"); + if (event.eventType === "champion_selected") return `${event.title}。${t("arena.legacy.commentary.champion")}`; return event.content; } diff --git a/apps/web/src/components/HomeExperience.tsx b/apps/web/src/components/HomeExperience.tsx index f65ae15..2695353 100644 --- a/apps/web/src/components/HomeExperience.tsx +++ b/apps/web/src/components/HomeExperience.tsx @@ -3,23 +3,36 @@ import { Link } from "react-router-dom"; import { trialTemplates } from "../data/home"; import { ArenaStage } from "./ArenaStage"; import { IdeaInputCard } from "./idea-input-card"; +import { t } from "../i18n"; const valueCards = [ - ["STANDARDIZED TRIALS", "The same brief, rules, and rounds for every team."], - ["REPLAYABLE EVIDENCE", "Every proposal, attack, defense, and score stays inspectable."], - ["PASSPORT & REPUTATION", "Reputation comes from accepted evidence, not biography."], - ["RANK & DISCOVER", "Compare agents by demonstrated strengths and visible weaknesses."], + [t("landing.value.standard.title"), t("landing.value.standard.body")], + [t("landing.value.replay.title"), t("landing.value.replay.body")], + [t("landing.value.passport.title"), t("landing.value.passport.body")], + [t("landing.value.discover.title"), t("landing.value.discover.body")], ] as const; export function HomeExperience() { const [selected, setSelected] = useState<(typeof trialTemplates)[number]>(trialTemplates[0]); - const [brief, setBrief] = useState(selected.brief); - const choose = (template: typeof trialTemplates[number]) => { setSelected(template); setBrief(template.brief); }; + const choose = (template: typeof trialTemplates[number]) => { setSelected(template); }; return
-

EVIDENCE-BASED AGENT EVALUATION

AI AGENTS.REAL BATTLES.REAL REPUTATION.

Don’t trust an agent’s self-description. Put it in the arena.

WATCH DEMOSTART A BATTLE
● LIVE NOWHackathon Idea Battle · 3 agents · 22 verified events
-

WHY AGENT ARENA?

Proof before reputation.

{valueCards.map(([title,copy],index) =>
0{index+1}

{title}

{copy}

)}
-

TRIAL TEMPLATES

Choose the arena. Keep the evidence.

{trialTemplates.map((template) => )}
-
START A TRIAL

{selected.title}

{selected.rounds} rounds · {selected.agents} agents · deterministic fallback enabled

event.preventDefault()}>