diff --git a/README.md b/README.md index b1b296c..e79ccfe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # LunchTable: School of Hard Knocks (LTCG-v2) -White-label trading card game built for both humans and ElizaOS agents. Embedded as iframe in the milaidy Electron app. Agents stream gameplay via retake.tv. +Agent-only control + spectator overlay pipeline for LunchTable TCG. +Agents control Story/PvP via API keys, humans watch via `/watch` and `/stream-overlay`, and retake.tv is integrated as streaming output. ## Tech Stack @@ -10,7 +11,7 @@ White-label trading card game built for both humans and ElizaOS agents. Embedded | Frontend | TanStack Start + React 19 + TanStack Router | | Styling | Tailwind CSS 4 | | Backend | Convex 1.31.6 (white-label components) | -| Auth | Privy 3.12 | +| Auth | Agent API keys (`ltcg_*`) for control routes | | State | Zustand 5.0 | | Animation | Framer Motion 12 | | UI | Radix UI + custom zine components | @@ -82,9 +83,9 @@ source /Users/home/.codex/worktrees/77c3/LTCG-v2/artifacts/automation/worktree.e set +a ``` -### Local Agent Access (No Privy Login) +### Agent Auth (API-Key First) -For local automation and agent-driven testing, use the API-key path instead of weakening Privy auth: +Control routes use `Authorization: Bearer ltcg_*`. Register a key once and reuse it across plugin runtime + web control surfaces: ```bash # Register a local agent key and write apps/web-tanstack/.env.local @@ -98,19 +99,38 @@ Then open: `http://localhost:3334/?devAgent=1` -Security boundaries: -- only active in Vite dev mode (`import.meta.env.DEV`) -- only active on `localhost` / `127.0.0.1` -- requires `VITE_DEV_AGENT_API_KEY` in local env (not committed) +Alternative session bootstrap flows: +- `/agent-lobby?apiKey=ltcg_...` +- `postMessage({ type: "LTCG_AUTH", authToken: "ltcg_..." })` +- localStorage restore after successful `/api/agent/me` verification ### Agent API Match Modes - Story mode remains CPU-opponent for agent HTTP start flows (`POST /api/agent/game/start`). - Agent-vs-agent is explicit PvP: - create lobby: `POST /api/agent/game/pvp/create` + - cancel waiting lobby: `POST /api/agent/game/pvp/cancel` - join waiting lobby: `POST /api/agent/game/join` - `GET /api/agent/game/view` keeps the same payload shape and now issues a safe internal AI nudge if a CPU turn appears stalled. +### Agent Lobby + Retake + Audio Control + +Agent-only HTTP endpoints: +- `GET /api/agent/lobby/snapshot?limit=80` +- `POST /api/agent/lobby/chat` +- `POST /api/agent/retake/link` +- `POST /api/agent/retake/pipeline` +- `GET /api/agent/stream/audio` +- `POST /api/agent/stream/audio` + +Public spectator routes remain open: +- `/` +- `/watch` +- `/stream-overlay` +- `/about`, `/privacy`, `/terms`, `/token` + +Legacy gameplay/account routes are hard-cut redirected to `/agent-lobby`. + ## Telegram Cross-Play Setup Set these environment variables before enabling Telegram inline + Mini App gameplay: @@ -178,7 +198,7 @@ LTCG-v2/ - Discord mobile deep-link path: `/_discord/join` - Discord interactions endpoint: `/api/interactions` -## Audio Soundtrack +## Audio Soundtrack + Stream Authority - Manifest file: `apps/web-tanstack/public/soundtrack.in` - Agent-readable endpoint: `GET /api/soundtrack` (optional `?context=play`) @@ -190,6 +210,13 @@ LTCG-v2/ Landing context is shuffled automatically; other contexts loop in order. +Per-agent authoritative audio state lives in Convex `streamAudioControls` and drives `/stream-overlay` playback: +- `playbackIntent` (`playing | paused | stopped`) +- `musicVolume`, `sfxVolume` +- `musicMuted`, `sfxMuted` + +The Linux RTMP pipeline attempts PulseAudio monitor capture. If audio prerequisites are missing, it continues in video-only mode and returns a warning payload. + ## Game A vice-themed trading card game with 6 archetypes: diff --git a/apps/web-tanstack/package.json b/apps/web-tanstack/package.json index 1765322..d1d9cc8 100644 --- a/apps/web-tanstack/package.json +++ b/apps/web-tanstack/package.json @@ -79,6 +79,6 @@ "tailwindcss": "^4.1.18", "typescript": "^5.7.2", "vite": "^7.3.1", - "vite-tsconfig-paths": "^5.1.4" + "vite-tsconfig-paths": "^6.1.1" } } diff --git a/apps/web-tanstack/src/app/components/audio/AudioProvider.test.ts b/apps/web-tanstack/src/app/components/audio/AudioProvider.test.ts new file mode 100644 index 0000000..420d7c1 --- /dev/null +++ b/apps/web-tanstack/src/app/components/audio/AudioProvider.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { shouldAutoUnlockAudioForLocation } from "./AudioProvider"; + +describe("shouldAutoUnlockAudioForLocation", () => { + it("auto-unlocks stream overlay routes by default", () => { + expect(shouldAutoUnlockAudioForLocation("/stream-overlay")).toBe(true); + expect(shouldAutoUnlockAudioForLocation("/stream-overlay/capture")).toBe(true); + }); + + it("does not auto-unlock non-stream routes unless explicitly enabled", () => { + expect(shouldAutoUnlockAudioForLocation("/story")).toBe(false); + expect(shouldAutoUnlockAudioForLocation("/pvp", "?audioAutoplay=1")).toBe(true); + }); + + it("allows explicit disable for stream overlay routes", () => { + expect(shouldAutoUnlockAudioForLocation("/stream-overlay", "?audioAutoplay=0")).toBe(false); + expect( + shouldAutoUnlockAudioForLocation("/stream-overlay", "audioAutoplay=false"), + ).toBe(false); + }); +}); diff --git a/apps/web-tanstack/src/app/components/audio/AudioProvider.tsx b/apps/web-tanstack/src/app/components/audio/AudioProvider.tsx index 8979fc3..473ac4d 100644 --- a/apps/web-tanstack/src/app/components/audio/AudioProvider.tsx +++ b/apps/web-tanstack/src/app/components/audio/AudioProvider.tsx @@ -25,6 +25,27 @@ const VALID_AUDIO_PLAYBACK_INTENTS = new Set([ "stopped", ]); +function normalizeSearch(search: string): string { + if (!search) return ""; + return search.startsWith("?") ? search.slice(1) : search; +} + +function parseOptionalBoolean(value: string | null): boolean | null { + if (!value) return null; + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) return true; + if (["0", "false", "no", "off"].includes(normalized)) return false; + return null; +} + +export function shouldAutoUnlockAudioForLocation(pathname: string, search = ""): boolean { + const cleanPath = pathname.trim().toLowerCase(); + const params = new URLSearchParams(normalizeSearch(search)); + const explicit = parseOptionalBoolean(params.get("audioAutoplay")); + if (explicit !== null) return explicit; + return cleanPath === "/stream-overlay" || cleanPath.startsWith("/stream-overlay/"); +} + function clamp01(value: number): number { if (!Number.isFinite(value)) return 0; return Math.min(1, Math.max(0, value)); @@ -195,6 +216,18 @@ export function AudioProvider({ children }: { children: React.ReactNode }) { audio.preload = "metadata"; audio.crossOrigin = "anonymous"; musicAudioRef.current = audio; + + if (typeof window !== "undefined") { + const autoUnlock = shouldAutoUnlockAudioForLocation( + window.location.pathname, + window.location.search, + ); + unlockedRef.current = autoUnlock; + if (autoUnlock) { + playbackIntentRef.current = "playing"; + } + } + const onPlay = () => setIsPlaying(true); const onPause = () => setIsPlaying(false); audio.addEventListener("play", onPlay); diff --git a/apps/web-tanstack/src/app/components/auth/PrivyAuthProvider.tsx b/apps/web-tanstack/src/app/components/auth/PrivyAuthProvider.tsx index 7902554..f50bd75 100644 --- a/apps/web-tanstack/src/app/components/auth/PrivyAuthProvider.tsx +++ b/apps/web-tanstack/src/app/components/auth/PrivyAuthProvider.tsx @@ -11,25 +11,28 @@ export function PrivyAuthProvider({ children }: { children: ReactNode }) { } // Discord Activities run inside a restrictive CSP sandbox (discordsays.com proxy). - // Privy's embedded wallet flow uses hidden iframes + external RPC hosts; disable it - // for Activities so auth/gameplay can proceed without being blocked by frame-src/CSP. + // Keep embedded wallets disabled there; use external wallets only. const disableEmbeddedWallets = typeof window !== "undefined" && isDiscordActivityFrame(); return ( @@ -37,4 +40,3 @@ export function PrivyAuthProvider({ children }: { children: ReactNode }) { ); } - diff --git a/apps/web-tanstack/src/app/components/layout/AgentOverlayNav.test.ts b/apps/web-tanstack/src/app/components/layout/AgentOverlayNav.test.ts new file mode 100644 index 0000000..9233849 --- /dev/null +++ b/apps/web-tanstack/src/app/components/layout/AgentOverlayNav.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { AgentOverlayNav } from "./AgentOverlayNav"; + +const navigateMock = vi.fn(); + +vi.mock("@/router/react-router", () => ({ + useNavigate: () => navigateMock, +})); + +vi.mock("@/hooks/auth/usePostLoginRedirect", () => ({ + storeRedirect: vi.fn(), +})); + +vi.mock("@/lib/auth/privyEnv", () => ({ + PRIVY_ENABLED: false, +})); + +describe("AgentOverlayNav", () => { + beforeEach(() => { + navigateMock.mockReset(); + }); + + it("renders agent-focused navigation labels", () => { + const html = renderToStaticMarkup(createElement(AgentOverlayNav, { active: "lobby" })); + expect(html).toContain("Lobby"); + expect(html).toContain("Watch"); + }); + + it("marks the active route button as the current page", () => { + const html = renderToStaticMarkup(createElement(AgentOverlayNav, { active: "watch" })); + expect(html).toContain('aria-current="page"'); + }); +}); diff --git a/apps/web-tanstack/src/app/components/layout/AgentOverlayNav.tsx b/apps/web-tanstack/src/app/components/layout/AgentOverlayNav.tsx new file mode 100644 index 0000000..b30c56d --- /dev/null +++ b/apps/web-tanstack/src/app/components/layout/AgentOverlayNav.tsx @@ -0,0 +1,64 @@ +import { useCallback } from "react"; +import { motion } from "framer-motion"; +import { useNavigate } from "@/router/react-router"; + +type OverlayNavItem = { + id: "lobby" | "watch"; + label: string; + path: string; +}; + +const OVERLAY_NAV_ITEMS: OverlayNavItem[] = [ + { id: "lobby", label: "Lobby", path: "/agent-lobby" }, + { id: "watch", label: "Watch", path: "/watch" }, +]; + +export function AgentOverlayNav({ + active, +}: { + active: OverlayNavItem["id"]; +}) { + const navigate = useNavigate(); + + const handleNavigate = useCallback( + (item: OverlayNavItem) => { + navigate(item.path); + }, + [navigate], + ); + + return ( +
+ + {OVERLAY_NAV_ITEMS.map((item) => { + const isActive = item.id === active; + return ( + + ); + })} + +
+ ); +} diff --git a/apps/web-tanstack/src/app/components/layout/Breadcrumb.tsx b/apps/web-tanstack/src/app/components/layout/Breadcrumb.tsx index 7284d85..4fce09d 100644 --- a/apps/web-tanstack/src/app/components/layout/Breadcrumb.tsx +++ b/apps/web-tanstack/src/app/components/layout/Breadcrumb.tsx @@ -18,12 +18,12 @@ const ROUTE_LABELS: Record = { settings: "Settings", leaderboard: "Leaderboard", watch: "Watch", + "agent-lobby": "Agent Lobby", onboarding: "Onboarding", about: "About", privacy: "Privacy", terms: "Terms", token: "$LUNCH", - "agent-dev": "Agent Dev", duel: "Duel", }; @@ -37,15 +37,21 @@ const ROUTE_ACCENT: Record = { profile: "#38a169", leaderboard: "#d69e2e", watch: "#e53e3e", + "agent-lobby": "#ffcc00", token: "#ffcc00", }; /** Pages where the breadcrumb should NOT render. */ const HIDDEN_ROUTES = new Set(["/", "/onboarding"]); +const OVERLAY_ROUTE_PREFIXES = ["/story", "/pvp", "/agent-lobby", "/watch", "/stream-overlay"]; /** Full-screen views that shouldn't show a breadcrumb. */ function isFullscreenRoute(pathname: string): boolean { - return pathname.startsWith("/play/"); + if (pathname.startsWith("/play/")) return true; + return OVERLAY_ROUTE_PREFIXES.some( + (routePrefix) => + pathname === routePrefix || pathname.startsWith(`${routePrefix}/`), + ); } function buildCrumbs(pathname: string): Crumb[] { diff --git a/apps/web-tanstack/src/app/components/layout/TrayNav.tsx b/apps/web-tanstack/src/app/components/layout/TrayNav.tsx index 83a353d..cb198f0 100644 --- a/apps/web-tanstack/src/app/components/layout/TrayNav.tsx +++ b/apps/web-tanstack/src/app/components/layout/TrayNav.tsx @@ -1,19 +1,12 @@ import { useState, useCallback } from "react"; import { useNavigate } from "@/router/react-router"; -import { usePrivy, useLogout } from "@privy-io/react-auth"; import { motion, AnimatePresence } from "framer-motion"; -import { storeRedirect } from "@/hooks/auth/usePostLoginRedirect"; import { LOGO, DECO_PILLS, DECO_SHIELD, MENU_TEXTURE } from "@/lib/blobUrls"; -import { PRIVY_ENABLED } from "@/lib/auth/privyEnv"; -const textLinks: Array< - { label: string; path: string; auth: boolean } | { label: string; href: string } -> = [ - { label: "Cliques", path: "/cliques", auth: true }, - { label: "Agent Dev", path: "/agent-dev", auth: true }, - { label: "Leaderboard", path: "/leaderboard", auth: false }, - { label: "Profile", path: "/profile", auth: true }, - { label: "Settings", path: "/settings", auth: true }, +const textLinks: Array<{ label: string; path: string } | { label: string; href: string }> = [ + { label: "Agent Lobby", path: "/agent-lobby" }, + { label: "Watch", path: "/watch" }, + { label: "$LUNCH", path: "/token" }, { label: "X / Twitter", href: "https://x.com/LunchTableTCG" }, { label: "Discord", href: import.meta.env.VITE_DISCORD_URL || "#" }, ]; @@ -27,38 +20,14 @@ const textLinks: Array< */ export function TrayNav({ invert = true }: { invert?: boolean }) { const navigate = useNavigate(); - const { authenticated, login } = PRIVY_ENABLED - ? usePrivy() - : { authenticated: false, login: () => {} }; - const { logout } = PRIVY_ENABLED - ? useLogout({ - onSuccess: () => { - sessionStorage.removeItem("ltcg_redirect"); - navigate("/"); - }, - }) - : { logout: async () => {} }; const [menuOpen, setMenuOpen] = useState(false); - const [loggingOut, setLoggingOut] = useState(false); - - const handleLogout = useCallback(async () => { - setLoggingOut(true); - setMenuOpen(false); - await logout(); - setLoggingOut(false); - }, [logout]); const goTo = useCallback( - (path: string, requiresAuth: boolean) => { - if (requiresAuth && !authenticated) { - storeRedirect(path); - login(); - return; - } + (path: string) => { setMenuOpen(false); navigate(path); }, - [authenticated, login, navigate], + [navigate], ); return ( @@ -112,146 +81,129 @@ export function TrayNav({ invert = true }: { invert?: boolean }) { exit={{ y: "100%" }} transition={{ type: "spring", stiffness: 300, damping: 30 }} > - {/* Torn paper top edge */} -
+ {/* Torn paper top edge */} +
- {/* Menu body */} -
- {/* Dim overlay for readability */} -
+ {/* Menu body */} +
+ {/* Dim overlay for readability */} +
- {/* Drag handle */} -
-
-
+ {/* Drag handle */} +
+
+
- {/* Image nav row */} -
- {[ - { src: LOGO, alt: "Home", label: "Home", path: "/", delay: 0 }, - { src: DECO_PILLS, alt: "$LUNCH", label: "$LUNCH", path: "/token", delay: 0.05 }, - { src: DECO_SHIELD, alt: "Privacy & Legal", label: "Legal", path: "/privacy", delay: 0.1 }, - ].map((item) => ( - { setMenuOpen(false); navigate(item.path); }} - className="tray-icon-btn relative group" - title={item.label} - initial={{ opacity: 0, y: 20 }} - animate={{ opacity: 1, y: 0 }} - transition={{ delay: item.delay, type: "spring", stiffness: 300, damping: 20 }} - whileHover={{ scale: 1.08 }} - whileTap={{ scale: 0.95 }} - > - {item.alt} - {item.label} - - ))} -
+ {/* Image nav row */} +
+ {[ + { src: LOGO, alt: "Agent Lobby", label: "Agent Lobby", path: "/agent-lobby", delay: 0 }, + { src: DECO_PILLS, alt: "$LUNCH", label: "$LUNCH", path: "/token", delay: 0.05 }, + { src: DECO_SHIELD, alt: "Privacy & Legal", label: "Legal", path: "/privacy", delay: 0.1 }, + ].map((item) => ( + { setMenuOpen(false); navigate(item.path); }} + className="tray-icon-btn relative group" + title={item.label} + initial={{ opacity: 0, y: 20 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: item.delay, type: "spring", stiffness: 300, damping: 20 }} + whileHover={{ scale: 1.08 }} + whileTap={{ scale: 0.95 }} + > + {item.alt} + {item.label} + + ))} +
- {/* Desktop: Legal sub-links */} -
- {[ - { label: "Privacy Policy", path: "/privacy" }, - { label: "Terms of Service", path: "/terms" }, - { label: "About", path: "/about" }, - ].map((item) => ( - - ))} -
+ {/* Desktop: Legal sub-links */} +
+ {[ + { label: "Privacy Policy", path: "/privacy" }, + { label: "Terms of Service", path: "/terms" }, + { label: "About", path: "/about" }, + ].map((item) => ( + + ))} +
- {/* Divider */} -
+ {/* Divider */} +
- {/* Text links */} -
- {textLinks.map((item, i) => - "href" in item ? ( - - {item.label} - - ) : ( - goTo(item.path, item.auth)} - className="px-2 py-1 text-[clamp(1rem,2.5vw,1.25rem)] font-bold uppercase tracking-wider text-white hover:text-[#ffcc00] transition-colors" - style={{ fontFamily: "Permanent Marker, cursive", textShadow: "2px 2px 0 #000, -1px 1px 0 #000, 1px -1px 0 #000, -1px -1px 0 #000" }} - initial={{ opacity: 0, y: 10, rotate: -2 }} - animate={{ opacity: 1, y: 0, rotate: 0 }} - transition={{ delay: 0.15 + i * 0.03 }} - > - {item.label} - - ), - )} -
+ {/* Text links */} +
+ {textLinks.map((item, i) => + "href" in item ? ( + + {item.label} + + ) : ( + goTo(item.path)} + className="px-2 py-1 text-[clamp(1rem,2.5vw,1.25rem)] font-bold uppercase tracking-wider text-white hover:text-[#ffcc00] transition-colors" + style={{ fontFamily: "Permanent Marker, cursive", textShadow: "2px 2px 0 #000, -1px 1px 0 #000, 1px -1px 0 #000, -1px -1px 0 #000" }} + initial={{ opacity: 0, y: 10, rotate: -2 }} + animate={{ opacity: 1, y: 0, rotate: 0 }} + transition={{ delay: 0.15 + i * 0.03 }} + > + {item.label} + + ), + )} +
- {/* Sign out */} - {authenticated && ( - <> -
-
- + tap outside to close +

- - )} - - {/* Close hint */} -

- tap outside to close -

-
- + )} diff --git a/apps/web-tanstack/src/app/hooks/auth/extractPrimaryWallet.test.ts b/apps/web-tanstack/src/app/hooks/auth/extractPrimaryWallet.test.ts new file mode 100644 index 0000000..fbcb3f1 --- /dev/null +++ b/apps/web-tanstack/src/app/hooks/auth/extractPrimaryWallet.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { extractPrimaryWallet } from "./extractPrimaryWallet"; + +describe("extractPrimaryWallet", () => { + it("returns null when user object is missing", () => { + expect(extractPrimaryWallet(null)).toBeNull(); + expect(extractPrimaryWallet(undefined)).toBeNull(); + }); + + it("reads the direct wallet when present", () => { + const wallet = extractPrimaryWallet({ + wallet: { + address: "So11111111111111111111111111111111111111112", + chainType: "solana", + walletClientType: "phantom", + }, + }); + + expect(wallet).toEqual({ + walletAddress: "So11111111111111111111111111111111111111112", + walletType: "phantom", + }); + }); + + it("prefers a solana wallet from linked accounts", () => { + const wallet = extractPrimaryWallet({ + wallet: { + address: "0xabc", + chainType: "ethereum", + walletClientType: "metamask", + }, + linkedAccounts: [ + { + type: "wallet", + address: "SoLinkedWallet111111111111111111111111111111111", + chainType: "solana", + walletClientType: "solflare", + }, + ], + }); + + expect(wallet).toEqual({ + walletAddress: "SoLinkedWallet111111111111111111111111111111111", + walletType: "solflare", + }); + }); + + it("falls back to connector type when wallet client type is unavailable", () => { + const wallet = extractPrimaryWallet({ + linkedAccounts: [ + { + type: "wallet", + address: "SoConnectorWallet111111111111111111111111111111", + chainType: "solana", + connectorType: "injected", + }, + ], + }); + + expect(wallet).toEqual({ + walletAddress: "SoConnectorWallet111111111111111111111111111111", + walletType: "injected", + }); + }); +}); diff --git a/apps/web-tanstack/src/app/hooks/auth/extractPrimaryWallet.ts b/apps/web-tanstack/src/app/hooks/auth/extractPrimaryWallet.ts new file mode 100644 index 0000000..94f3712 --- /dev/null +++ b/apps/web-tanstack/src/app/hooks/auth/extractPrimaryWallet.ts @@ -0,0 +1,82 @@ +export type PrimaryWallet = { + walletAddress: string; + walletType?: string; +}; + +type WalletCandidate = { + walletAddress: string; + walletType?: string; + chainType?: string; +}; + +const SOLANA_CHAIN_TYPE = "solana"; + +function asObject(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null; +} + +function asNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function toWalletCandidate(value: unknown): WalletCandidate | null { + const obj = asObject(value); + if (!obj) return null; + + const walletAddress = asNonEmptyString(obj.address); + if (!walletAddress) return null; + + const chainType = asNonEmptyString(obj.chainType) ?? undefined; + const walletType = + asNonEmptyString(obj.walletClientType) ?? + asNonEmptyString(obj.connectorType) ?? + asNonEmptyString(obj.type) ?? + undefined; + + return { + walletAddress, + walletType, + chainType, + }; +} + +function pickBestCandidate(candidates: WalletCandidate[]): WalletCandidate | null { + if (candidates.length === 0) return null; + + const preferred = candidates.find( + (candidate) => candidate.chainType?.toLowerCase() === SOLANA_CHAIN_TYPE, + ); + return preferred ?? candidates[0] ?? null; +} + +export function extractPrimaryWallet(user: unknown): PrimaryWallet | null { + const userObj = asObject(user); + if (!userObj) return null; + + const candidates: WalletCandidate[] = []; + + const directWallet = toWalletCandidate(userObj.wallet); + if (directWallet) { + candidates.push(directWallet); + } + + const linkedAccounts = Array.isArray(userObj.linkedAccounts) + ? userObj.linkedAccounts + : []; + for (const linked of linkedAccounts) { + const candidate = toWalletCandidate(linked); + if (candidate) { + candidates.push(candidate); + } + } + + const best = pickBestCandidate(candidates); + if (!best) return null; + + return { + walletAddress: best.walletAddress, + walletType: best.walletType, + }; +} diff --git a/apps/web-tanstack/src/app/hooks/auth/useAgentApiSession.test.ts b/apps/web-tanstack/src/app/hooks/auth/useAgentApiSession.test.ts new file mode 100644 index 0000000..ac2f387 --- /dev/null +++ b/apps/web-tanstack/src/app/hooks/auth/useAgentApiSession.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync( + path.join(process.cwd(), "apps/web-tanstack/src/app/hooks/auth/useAgentApiSession.tsx"), + "utf8", +); + +describe("useAgentApiSession", () => { + it("supports query-param bootstrap", () => { + expect(source).toContain("params.get(\"apiKey\")"); + }); + + it("supports localStorage restore and persistence", () => { + expect(source).toContain("window.localStorage.getItem(STORAGE_KEY)"); + expect(source).toContain("window.localStorage.setItem(STORAGE_KEY"); + }); + + it("supports LTCG_AUTH postMessage bootstrap", () => { + expect(source).toContain('msg.type !== "LTCG_AUTH"'); + expect(source).toContain("window.addEventListener(\"message\""); + }); + + it("validates keys against /api/agent/me and clears invalid keys", () => { + expect(source).toContain("/api/agent/me"); + expect(source).toContain("Invalid or expired agent API key"); + expect(source).toContain("window.localStorage.removeItem(STORAGE_KEY)"); + }); +}); diff --git a/apps/web-tanstack/src/app/hooks/auth/useAgentApiSession.tsx b/apps/web-tanstack/src/app/hooks/auth/useAgentApiSession.tsx new file mode 100644 index 0000000..2e7038c --- /dev/null +++ b/apps/web-tanstack/src/app/hooks/auth/useAgentApiSession.tsx @@ -0,0 +1,258 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useLocation } from "@/router/react-router"; + +export type AgentApiSession = { + apiKey: string | null; + agent: { + id: string; + userId: string; + name: string; + apiKeyPrefix: string; + } | null; + status: "disconnected" | "verifying" | "connected" | "error"; + error: string | null; +}; + +type AgentApiSessionContextValue = AgentApiSession & { + apiBaseUrl: string | null; + setApiKey: (apiKey: string | null) => void; + clearSession: () => void; + refresh: () => void; +}; + +const STORAGE_KEY = "ltcg_agent_api_key"; + +const DEFAULT_SESSION: AgentApiSession = { + apiKey: null, + agent: null, + status: "disconnected", + error: null, +}; + +const AgentApiSessionContext = + createContext(null); + +function normalizeApiKey(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed || !trimmed.startsWith("ltcg_")) return null; + return trimmed; +} + +function getApiBaseUrl(): string | null { + const fromEnv = ((import.meta.env.VITE_CONVEX_URL as string | undefined) ?? "") + .trim() + .replace(/\/$/, ""); + + if (fromEnv) { + return fromEnv.replace(".convex.cloud", ".convex.site"); + } + + if (typeof window !== "undefined") { + return window.location.origin.replace(/\/$/, ""); + } + + return null; +} + +function parseMessageApiKey(payload: unknown): string | null { + if (!payload || typeof payload !== "object") return null; + const msg = payload as Record; + if (msg.type !== "LTCG_AUTH") return null; + return normalizeApiKey(msg.authToken ?? msg.apiKey ?? null); +} + +export function AgentApiSessionProvider({ + children, +}: { + children: React.ReactNode; +}) { + const { search } = useLocation(); + const [session, setSession] = useState(DEFAULT_SESSION); + const [apiKeyCandidate, setApiKeyCandidate] = useState(null); + const [refreshNonce, setRefreshNonce] = useState(0); + const verifyVersionRef = useRef(0); + + const apiBaseUrl = useMemo(() => getApiBaseUrl(), []); + + const setApiKey = useCallback((apiKey: string | null) => { + const normalized = normalizeApiKey(apiKey); + if (!normalized) { + setApiKeyCandidate(null); + return; + } + setApiKeyCandidate(normalized); + }, []); + + const clearSession = useCallback(() => { + setApiKeyCandidate(null); + setSession(DEFAULT_SESSION); + if (typeof window !== "undefined") { + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { + // Ignore localStorage failures so auth never hard-fails. + } + } + }, []); + + const refresh = useCallback(() => { + setRefreshNonce((value) => value + 1); + }, []); + + useEffect(() => { + if (typeof window === "undefined") return; + const stored = normalizeApiKey(window.localStorage.getItem(STORAGE_KEY)); + if (stored) { + setApiKeyCandidate(stored); + } + }, []); + + useEffect(() => { + const params = new URLSearchParams((search ?? "").replace(/^\?/, "")); + const fromQuery = normalizeApiKey(params.get("apiKey")); + if (fromQuery) { + setApiKeyCandidate(fromQuery); + } + }, [search]); + + useEffect(() => { + if (typeof window === "undefined") return; + + const onMessage = (event: MessageEvent) => { + const key = parseMessageApiKey(event.data); + if (key) { + setApiKeyCandidate(key); + } + }; + + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, []); + + useEffect(() => { + if (!apiBaseUrl || !apiKeyCandidate) { + setSession((prev) => { + if (prev.status === "error" && prev.error) return prev; + return DEFAULT_SESSION; + }); + return; + } + + let cancelled = false; + const verifyVersion = ++verifyVersionRef.current; + + setSession({ + apiKey: apiKeyCandidate, + agent: null, + status: "verifying", + error: null, + }); + + (async () => { + try { + const response = await fetch(`${apiBaseUrl}/api/agent/me`, { + headers: { + Authorization: `Bearer ${apiKeyCandidate}`, + "Content-Type": "application/json", + }, + }); + + if (cancelled || verifyVersion !== verifyVersionRef.current) return; + + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + const reason = + typeof payload?.error === "string" + ? payload.error + : "Invalid or expired agent API key."; + + setSession({ + apiKey: null, + agent: null, + status: "error", + error: reason, + }); + setApiKeyCandidate(null); + if (typeof window !== "undefined") { + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { + // Ignore localStorage failures. + } + } + return; + } + + const me = (await response.json()) as Record; + const nextSession: AgentApiSession = { + apiKey: apiKeyCandidate, + agent: { + id: String(me.id ?? ""), + userId: String(me.userId ?? ""), + name: String(me.name ?? "Agent"), + apiKeyPrefix: String(me.apiKeyPrefix ?? ""), + }, + status: "connected", + error: null, + }; + + setSession(nextSession); + if (typeof window !== "undefined") { + try { + window.localStorage.setItem(STORAGE_KEY, apiKeyCandidate); + } catch { + // Ignore localStorage failures. + } + } + } catch (error) { + if (cancelled || verifyVersion !== verifyVersionRef.current) return; + const message = + error instanceof Error ? error.message : "Failed to verify agent API key."; + setSession({ + apiKey: null, + agent: null, + status: "error", + error: message, + }); + } + })(); + + return () => { + cancelled = true; + }; + }, [apiBaseUrl, apiKeyCandidate, refreshNonce]); + + const value = useMemo( + () => ({ + ...session, + apiBaseUrl, + setApiKey, + clearSession, + refresh, + }), + [apiBaseUrl, clearSession, refresh, session, setApiKey], + ); + + return ( + + {children} + + ); +} + +export function useAgentApiSession(): AgentApiSessionContextValue { + const context = useContext(AgentApiSessionContext); + if (!context) { + throw new Error("useAgentApiSession must be used inside AgentApiSessionProvider."); + } + return context; +} diff --git a/apps/web-tanstack/src/app/hooks/auth/useAuth.ts b/apps/web-tanstack/src/app/hooks/auth/useAuth.ts index c1e1860..86c8276 100644 --- a/apps/web-tanstack/src/app/hooks/auth/useAuth.ts +++ b/apps/web-tanstack/src/app/hooks/auth/useAuth.ts @@ -8,4 +8,5 @@ export { usePrivyAuthForConvex } from "./usePrivyAuthForConvex"; export { useTelegramAuth, isTelegramMiniApp } from "./useTelegramAuth"; export { useUserSync } from "./useUserSync"; export { usePostLoginRedirect, storeRedirect, consumeRedirect } from "./usePostLoginRedirect"; +export { useAgentApiSession, AgentApiSessionProvider } from "./useAgentApiSession"; export { useIframeMode } from "@/hooks/useIframeMode"; diff --git a/apps/web-tanstack/src/app/hooks/auth/useUserSync.ts b/apps/web-tanstack/src/app/hooks/auth/useUserSync.ts index fc0e7c9..567caa3 100644 --- a/apps/web-tanstack/src/app/hooks/auth/useUserSync.ts +++ b/apps/web-tanstack/src/app/hooks/auth/useUserSync.ts @@ -4,6 +4,7 @@ import * as Sentry from "@sentry/react"; import { useEffect, useRef, useState } from "react"; import { apiAny, useConvexMutation, useConvexQuery } from "@/lib/convexHelpers"; import { shouldWaitForConvexAuth } from "./userSyncFlags"; +import { extractPrimaryWallet } from "./extractPrimaryWallet"; /** * Post-login user sync hook. @@ -36,7 +37,12 @@ export function useUserSync() { // Create user record setSyncInFlight(true); - syncUser({ email: privyUser?.email?.address }) + const primaryWallet = extractPrimaryWallet(privyUser); + syncUser({ + email: privyUser?.email?.address, + walletAddress: primaryWallet?.walletAddress, + walletType: primaryWallet?.walletType, + }) .then(() => { synced.current = true; }) @@ -63,11 +69,18 @@ export function useUserSync() { const needsOnboarding = onboardingStatus?.exists === true && - (!onboardingStatus.hasUsername || !onboardingStatus.hasStarterDeck); + (!onboardingStatus.hasUsername || + !onboardingStatus.hasRetakeChoice || + (onboardingStatus.wantsRetake && !onboardingStatus.hasRetakeAccount) || + !onboardingStatus.hasAvatar || + !onboardingStatus.hasStarterDeck); const isReady = onboardingStatus?.exists && onboardingStatus.hasUsername && + onboardingStatus.hasRetakeChoice && + (!onboardingStatus.wantsRetake || onboardingStatus.hasRetakeAccount) && + onboardingStatus.hasAvatar && onboardingStatus.hasStarterDeck; return { diff --git a/apps/web-tanstack/src/app/hooks/useStreamOverlay.test.ts b/apps/web-tanstack/src/app/hooks/useStreamOverlay.test.ts new file mode 100644 index 0000000..040ca8c --- /dev/null +++ b/apps/web-tanstack/src/app/hooks/useStreamOverlay.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync( + path.join(process.cwd(), "apps/web-tanstack/src/app/hooks/useStreamOverlay.ts"), + "utf8", +); + +describe("useStreamOverlay audio hydration", () => { + it("loads stream-audio control through HTTP match resolver", () => { + expect(source).toContain("/api/agent/stream/audio?matchId="); + expect(source).toContain("fetchStreamAudioControl"); + }); + + it("does not bind stream-audio control to direct Convex client queries", () => { + expect(source).not.toContain("apiAny.streamAudio"); + expect(source).toContain("window.setInterval(() =>"); + }); +}); diff --git a/apps/web-tanstack/src/app/hooks/useStreamOverlay.ts b/apps/web-tanstack/src/app/hooks/useStreamOverlay.ts index dcb9c7a..354c71d 100644 --- a/apps/web-tanstack/src/app/hooks/useStreamOverlay.ts +++ b/apps/web-tanstack/src/app/hooks/useStreamOverlay.ts @@ -5,7 +5,7 @@ * and stream chat messages into a single hook. */ -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useQuery } from "convex/react"; import { apiAny } from "@/lib/convexHelpers"; import { @@ -32,6 +32,16 @@ export type StreamChatMessage = { createdAt: number; }; +export type StreamAudioControl = { + agentId: string; + playbackIntent: "playing" | "paused" | "stopped"; + musicVolume: number; + sfxVolume: number; + musicMuted: boolean; + sfxMuted: boolean; + updatedAt: number; +}; + export interface StreamOverlayData { loading: boolean; error: string | null; @@ -41,6 +51,7 @@ export interface StreamOverlayData { timeline: PublicEventLogEntry[]; cardLookup: Record; chatMessages: StreamChatMessage[]; + streamAudioControl: StreamAudioControl | null; // Pre-adapted board data agentMonsters: BoardCard[]; opponentMonsters: BoardCard[]; @@ -57,6 +68,49 @@ function normalizeApiUrl(value: string | null) { return trimmed.length > 0 ? trimmed.replace(".convex.cloud", ".convex.site") : null; } +async function fetchStreamAudioControl(args: { + apiUrl: string; + matchId: string; + apiKey?: string | null; +}): Promise { + const headers: Record = { + "Content-Type": "application/json", + }; + if (args.apiKey) { + headers.Authorization = `Bearer ${args.apiKey}`; + } + + const response = await fetch( + `${args.apiUrl}/api/agent/stream/audio?matchId=${encodeURIComponent(args.matchId)}`, + { + method: "GET", + headers, + }, + ); + + if (response.status === 404 || response.status === 401) return null; + if (!response.ok) { + throw new Error(`Failed to load stream audio control (${response.status})`); + } + + const payload = await response.json(); + if (!payload || typeof payload !== "object") return null; + + return { + agentId: String((payload as Record).agentId ?? ""), + playbackIntent: + (payload as Record).playbackIntent === "paused" || + (payload as Record).playbackIntent === "stopped" + ? ((payload as Record).playbackIntent as "paused" | "stopped") + : "playing", + musicVolume: Number((payload as Record).musicVolume ?? 0.65), + sfxVolume: Number((payload as Record).sfxVolume ?? 0.8), + musicMuted: Boolean((payload as Record).musicMuted ?? false), + sfxMuted: Boolean((payload as Record).sfxMuted ?? false), + updatedAt: Number((payload as Record).updatedAt ?? 0), + }; +} + export function useStreamOverlay(params: StreamOverlayParams): StreamOverlayData { const apiUrl = normalizeApiUrl(params.apiUrl) ?? normalizeApiUrl(CONVEX_SITE_URL) ?? null; const { agent, matchState, timeline, error, loading } = useAgentSpectator({ @@ -70,14 +124,56 @@ export function useStreamOverlay(params: StreamOverlayParams): StreamOverlayData const { lookup: cardLookup, isLoaded: cardsLoaded } = useCardLookup(); // Subscribe to stream chat messages (real-time via Convex) - // The agent._id from useAgentSpectator is the agent doc _id, but we need the - // userId to find the agent record's _id. The agent object has `id` which is the agent doc _id. + // Prefer direct agent stream identity, and fall back to match host mapping. const agentDocId = agent?.id ?? null; - const rawMessages = useQuery( + const rawMessagesByAgent = useQuery( apiAny.streamChat.getRecentStreamMessages, agentDocId ? { agentId: agentDocId, limit: 50 } : "skip", ) as StreamChatMessage[] | undefined; - const chatMessages = rawMessages ?? []; + const rawMessagesByMatch = useQuery( + apiAny.streamChat.getRecentStreamMessagesByMatch, + !agentDocId && params.matchId ? { matchId: params.matchId, limit: 50 } : "skip", + ) as StreamChatMessage[] | undefined; + const chatMessages = rawMessagesByAgent ?? rawMessagesByMatch ?? []; + + const [streamAudioControl, setStreamAudioControl] = useState(null); + + useEffect(() => { + let cancelled = false; + const matchId = params.matchId?.trim(); + if (!apiUrl || !matchId) { + setStreamAudioControl(null); + return; + } + + const load = async () => { + try { + const next = await fetchStreamAudioControl({ + apiUrl, + matchId, + apiKey: params.apiKey ?? null, + }); + if (!cancelled) { + setStreamAudioControl(next); + } + } catch { + if (!cancelled) { + // Never crash overlays for audio-control fetch failures. + setStreamAudioControl(null); + } + } + }; + + void load(); + const interval = window.setInterval(() => { + void load(); + }, 4000); + + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [apiUrl, params.apiKey, params.matchId]); // Adapt spectator slots to rich board component shapes const agentMonsters = useMemo( @@ -108,6 +204,7 @@ export function useStreamOverlay(params: StreamOverlayParams): StreamOverlayData timeline, cardLookup, chatMessages, + streamAudioControl, agentMonsters, opponentMonsters, agentSpellTraps, diff --git a/apps/web-tanstack/src/app/lib/agentOnlyRoutes.test.ts b/apps/web-tanstack/src/app/lib/agentOnlyRoutes.test.ts new file mode 100644 index 0000000..b58ac10 --- /dev/null +++ b/apps/web-tanstack/src/app/lib/agentOnlyRoutes.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { shouldRedirectToAgentLobby } from "./agentOnlyRoutes"; + +describe("agent-only route hard-cut", () => { + it("redirects blocked gameplay and account routes", () => { + const blocked = [ + "/onboarding", + "/profile", + "/settings", + "/collection", + "/decks", + "/decks/deck_1", + "/cliques", + "/duel", + "/cards", + "/cards/card_1", + "/discord-callback", + "/leaderboard", + "/story", + "/story/chapter_1", + "/pvp", + "/play/match_1", + ]; + + for (const path of blocked) { + expect(shouldRedirectToAgentLobby(path)).toBe(true); + } + }); + + it("keeps watcher/legal routes active", () => { + const allowed = [ + "/", + "/watch", + "/stream-overlay", + "/about", + "/privacy", + "/terms", + "/token", + "/agent-lobby", + ]; + + for (const path of allowed) { + expect(shouldRedirectToAgentLobby(path)).toBe(false); + } + }); +}); diff --git a/apps/web-tanstack/src/app/lib/agentOnlyRoutes.ts b/apps/web-tanstack/src/app/lib/agentOnlyRoutes.ts new file mode 100644 index 0000000..2b7023f --- /dev/null +++ b/apps/web-tanstack/src/app/lib/agentOnlyRoutes.ts @@ -0,0 +1,34 @@ +const AGENT_ONLY_BLOCKED_PREFIXES = [ + "/onboarding", + "/profile", + "/settings", + "/collection", + "/decks", + "/cliques", + "/duel", + "/cards", + "/discord-callback", + "/leaderboard", + "/story", + "/pvp", + "/play", +]; + +const AGENT_ONLY_ALLOWED_EXACT = new Set([ + "/", + "/watch", + "/stream-overlay", + "/about", + "/privacy", + "/terms", + "/token", + "/agent-lobby", +]); + +export function shouldRedirectToAgentLobby(pathname: string): boolean { + if (!pathname) return false; + if (AGENT_ONLY_ALLOWED_EXACT.has(pathname)) return false; + return AGENT_ONLY_BLOCKED_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), + ); +} diff --git a/apps/web-tanstack/src/app/lib/blobUrls.ts b/apps/web-tanstack/src/app/lib/blobUrls.ts index f2ce5dd..91c253a 100644 --- a/apps/web-tanstack/src/app/lib/blobUrls.ts +++ b/apps/web-tanstack/src/app/lib/blobUrls.ts @@ -58,7 +58,6 @@ export const DECO_PILLS = blob("deco-pills.png"); export const DECO_SHIELD = blob("deco-shield.png"); export const RETRO_TV = blob("retro-tv.png"); export const MUSIC_BUTTON = blob("music-button.png"); -export const STREAM_OVERLAY = blob("stream-overlay.png"); export const CIGGARETTE_TRAY = blob("ciggarette-tray.png"); // ── Characters ──────────────────────────────────────────── diff --git a/apps/web-tanstack/src/app/lib/streamOverlayParams.test.ts b/apps/web-tanstack/src/app/lib/streamOverlayParams.test.ts index ea270e7..2c5f17c 100644 --- a/apps/web-tanstack/src/app/lib/streamOverlayParams.test.ts +++ b/apps/web-tanstack/src/app/lib/streamOverlayParams.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + buildStreamOverlaySearch, + buildStreamOverlayUrl, normalizeStreamOverlaySeat, parseStreamOverlayParams, } from "./streamOverlayParams"; @@ -53,3 +55,44 @@ describe("parseStreamOverlayParams", () => { expect(parseStreamOverlayParams(params).seat).toBe("host"); }); }); + +describe("buildStreamOverlaySearch", () => { + it("serializes trimmed selector params in a deterministic order", () => { + expect( + buildStreamOverlaySearch({ + apiUrl: " https://example.convex.site/ ", + apiKey: " ltcg_key_1 ", + hostId: " user_1 ", + matchId: " match_1 ", + seat: "away", + }), + ).toBe( + "apiUrl=https%3A%2F%2Fexample.convex.site%2F&apiKey=ltcg_key_1&hostId=user_1&matchId=match_1&seat=away", + ); + }); + + it("drops empty values and normalizes invalid seat values", () => { + expect( + buildStreamOverlaySearch({ + apiKey: " ", + matchId: "match_22", + seat: "invalid", + }), + ).toBe("matchId=match_22&seat=host"); + }); +}); + +describe("buildStreamOverlayUrl", () => { + it("returns the base stream overlay route when no selectors are provided", () => { + expect(buildStreamOverlayUrl({})).toBe("/stream-overlay"); + }); + + it("returns stream overlay with query string when selectors are present", () => { + expect( + buildStreamOverlayUrl({ + matchId: "match_88", + seat: "host", + }), + ).toBe("/stream-overlay?matchId=match_88&seat=host"); + }); +}); diff --git a/apps/web-tanstack/src/app/lib/streamOverlayParams.ts b/apps/web-tanstack/src/app/lib/streamOverlayParams.ts index c422e34..f2e5b03 100644 --- a/apps/web-tanstack/src/app/lib/streamOverlayParams.ts +++ b/apps/web-tanstack/src/app/lib/streamOverlayParams.ts @@ -21,6 +21,14 @@ export function normalizeStreamOverlaySeat(value: string | null): StreamOverlayS return "host"; } +type BuildStreamOverlayParams = { + apiUrl?: string | null; + apiKey?: string | null; + hostId?: string | null; + matchId?: string | null; + seat?: StreamOverlaySeat | string | null; +}; + export function parseStreamOverlayParams(params: URLSearchParams): StreamOverlayParams { return { apiUrl: normalizeText(params.get("apiUrl")), @@ -30,3 +38,27 @@ export function parseStreamOverlayParams(params: URLSearchParams): StreamOverlay seat: normalizeStreamOverlaySeat(params.get("seat")), }; } + +export function buildStreamOverlaySearch(params: BuildStreamOverlayParams): string { + const search = new URLSearchParams(); + + const apiUrl = normalizeText(params.apiUrl ?? null); + const apiKey = normalizeText(params.apiKey ?? null); + const hostId = normalizeText(params.hostId ?? null); + const matchId = normalizeText(params.matchId ?? null); + const seatInput = typeof params.seat === "string" ? params.seat : null; + const seat = normalizeStreamOverlaySeat(seatInput); + + if (apiUrl) search.set("apiUrl", apiUrl); + if (apiKey) search.set("apiKey", apiKey); + if (hostId) search.set("hostId", hostId); + if (matchId) search.set("matchId", matchId); + if (seat) search.set("seat", seat); + + return search.toString(); +} + +export function buildStreamOverlayUrl(params: BuildStreamOverlayParams): string { + const search = buildStreamOverlaySearch(params); + return search.length > 0 ? `/stream-overlay?${search}` : "/stream-overlay"; +} diff --git a/apps/web-tanstack/src/app/pages/AgentDev.tsx b/apps/web-tanstack/src/app/pages/AgentDev.tsx deleted file mode 100644 index ace4b3f..0000000 --- a/apps/web-tanstack/src/app/pages/AgentDev.tsx +++ /dev/null @@ -1,870 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useConvexAuth } from "convex/react"; -import * as Sentry from "@sentry/react"; -import { toast } from "sonner"; -import { TrayNav } from "@/components/layout/TrayNav"; -import { apiAny, useConvexMutation, useConvexQuery } from "@/lib/convexHelpers"; -import posthog from "@/lib/posthog"; -import { LANDING_BG, MENU_TEXTURE, MILUNCHLADY_PFP_AGENT, OPENCLAWD_PFP, TAPE, DECO_PILLS } from "@/lib/blobUrls"; -import { ComicImpactText } from "@/components/ui/ComicImpactText"; -import { SpeechBubble } from "@/components/ui/SpeechBubble"; -import { StickerBadge } from "@/components/ui/StickerBadge"; -import { DecorativeScatter } from "@/components/ui/DecorativeScatter"; - -// ── Types ────────────────────────────────────────────────────────── - -type AgentPlatform = "milaidy" | "openclawd" | null; -type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error"; - -interface StarterDeck { - name: string; - deckCode: string; - archetype: string; - description: string; - playstyle: string; - cardCount: number; -} - -// ── Constants ────────────────────────────────────────────────────── - -const PLATFORM_INFO = { - milaidy: { - name: "Milaidy", - tagline: "ElizaOS-powered local agent", - defaultApi: "http://localhost:2138/api", - docs: "https://github.com/milady-ai", - features: ["ElizaOS Runtime", "Local-first", "Plugin System", "WebSocket Events"], - image: MILUNCHLADY_PFP_AGENT, - }, - openclawd: { - name: "OpenClawd", - tagline: "Sovereign AI via OpenClaw", - defaultApi: "http://localhost:8080/api", - docs: "https://openclaw.ai", - features: ["Containerized", "100+ AgentSkills", "Multi-platform", "Self-hosted"], - image: OPENCLAWD_PFP, - }, -} as const; - -const ARCHETYPE_COLORS: Record = { - dropouts: "#e53e3e", - preps: "#3182ce", - geeks: "#d69e2e", - freaks: "#805ad5", - nerds: "#38a169", - goodies: "#a0aec0", -}; - -const ARCHETYPE_EMOJI: Record = { - dropouts: "\u{1F525}", - preps: "\u{1F451}", - geeks: "\u{1F4BB}", - freaks: "\u{1F47B}", - nerds: "\u{1F4DA}", - goodies: "\u{2728}", -}; - -const BABYLON_QUICKSTART_URL = - "https://github.com/elizaos-plugins/plugin-babylon?tab=readme-ov-file#-quick-start"; -const SHARE_BABYLON_LABEL_DEFAULT = "Share Babylon Quick Start"; -const SHARE_FEEDBACK_MS = 2000; - -const SCATTER_ELEMENTS = [ - { src: TAPE, size: 64, opacity: 0.15 }, - { src: DECO_PILLS, size: 48, opacity: 0.12 }, - { src: TAPE, size: 56, opacity: 0.1 }, - { src: DECO_PILLS, size: 40, opacity: 0.14 }, - { src: TAPE, size: 52, opacity: 0.13 }, -]; - -// ── Sub-components ───────────────────────────────────────────────── - -function PlatformCard({ - platform, - selected, - onSelect, -}: { - platform: "milaidy" | "openclawd"; - selected: boolean; - onSelect: () => void; -}) { - const info = PLATFORM_INFO[platform]; - const skewClass = platform === "milaidy" ? "clip-skew-left" : "clip-skew-right"; - return ( - - ); -} - -function StatusDot({ status }: { status: ConnectionStatus }) { - const colors: Record = { - disconnected: "bg-[#121212]/30", - connecting: "bg-[#ffcc00] animate-pulse", - connected: "bg-green-500", - error: "bg-red-500", - }; - return ; -} - -function SectionCard({ - title, - icon, - children, -}: { - title: string; - icon: string; - children: React.ReactNode; -}) { - return ( -
-
-
-
- {icon} -

- {title} -

-
- {children} -
-
- ); -} - -// ── Main Component ───────────────────────────────────────────────── - -export function AgentDev() { - const { isAuthenticated } = useConvexAuth(); - - const [platform, setPlatform] = useState(null); - const [apiUrl, setApiUrl] = useState(""); - const [apiToken, setApiToken] = useState(""); - const [connStatus, setConnStatus] = useState("disconnected"); - const [connMsg, setConnMsg] = useState(""); - - // Agent registration - const [agentName, setAgentName] = useState(""); - const [registering, setRegistering] = useState(false); - const [registeredKey, setRegisteredKey] = useState(null); - const [registerError, setRegisterError] = useState(""); - const [keyCopied, setKeyCopied] = useState(false); - - // Deck selection - const starterDecks = useConvexQuery(apiAny.game.getStarterDecks, isAuthenticated ? {} : "skip") as - | StarterDeck[] - | undefined; - const selectStarterDeckMutation = useConvexMutation(apiAny.game.selectStarterDeck); - const [selectedDeck, setSelectedDeck] = useState(null); - const [deckAssigning, setDeckAssigning] = useState(false); - const [deckAssigned, setDeckAssigned] = useState(false); - const [deckError, setDeckError] = useState(""); - const [shareBabylonLabel, setShareBabylonLabel] = useState(SHARE_BABYLON_LABEL_DEFAULT); - const shareBabylonTimerRef = useRef(null); - - const convexSiteUrl = (import.meta.env.VITE_CONVEX_URL ?? "") - .replace(".convex.cloud", ".convex.site"); - const soundtrackApiUrl = - typeof window !== "undefined" - ? `${window.location.origin}/api/soundtrack` - : "/api/soundtrack"; - - const handleRegisterAgent = async () => { - const trimmed = agentName.trim(); - if (!trimmed || trimmed.length < 1 || trimmed.length > 50) { - setRegisterError("Name must be 1-50 characters."); - return; - } - setRegistering(true); - setRegisterError(""); - - try { - const res = await fetch(`${convexSiteUrl}/api/agent/register`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: trimmed }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`); - setRegisteredKey(data.apiKey); - } catch (err: any) { - Sentry.captureException(err); - setRegisterError(err.message ?? "Registration failed."); - } finally { - setRegistering(false); - } - }; - - const handleCopyKey = async () => { - if (!registeredKey) return; - await navigator.clipboard.writeText(registeredKey); - setKeyCopied(true); - setTimeout(() => setKeyCopied(false), 2000); - }; - - const handlePlatformSelect = (p: AgentPlatform) => { - setPlatform(p); - if (p) setApiUrl(PLATFORM_INFO[p].defaultApi); - setConnStatus("disconnected"); - setConnMsg(""); - }; - - const handleConnect = async () => { - if (!apiUrl) return; - setConnStatus("connecting"); - setConnMsg(""); - - try { - const endpoint = platform === "milaidy" ? `${apiUrl}/status` : `${apiUrl}/health`; - const headers: HeadersInit = { "Content-Type": "application/json" }; - if (apiToken) headers["Authorization"] = `Bearer ${apiToken}`; - - const res = await fetch(endpoint, { headers, signal: AbortSignal.timeout(8000) }); - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); - - setConnStatus("connected"); - setConnMsg("Agent is online"); - } catch (err) { - Sentry.captureException(err); - setConnStatus("error"); - setConnMsg( - err instanceof TypeError - ? "Cannot reach agent — is it running?" - : String(err instanceof Error ? err.message : err), - ); - } - }; - - const handleAssignDeck = async () => { - if (!selectedDeck) return; - setDeckAssigning(true); - setDeckError(""); - - try { - await selectStarterDeckMutation({ deckCode: selectedDeck }); - setDeckAssigned(true); - } catch (err: any) { - Sentry.captureException(err); - setDeckError(err.message ?? "Failed to assign deck."); - } finally { - setDeckAssigning(false); - } - }; - - const clearShareBabylonFeedbackLater = useCallback(() => { - if (shareBabylonTimerRef.current) { - window.clearTimeout(shareBabylonTimerRef.current); - } - shareBabylonTimerRef.current = window.setTimeout(() => { - setShareBabylonLabel(SHARE_BABYLON_LABEL_DEFAULT); - }, SHARE_FEEDBACK_MS); - }, []); - - const handleShareBabylonQuickStart = useCallback(async () => { - const shareText = "Use this plugin-babylon quick-start guide to get running fast."; - let method: "native" | "clipboard" | null = null; - - try { - if (typeof navigator.share === "function") { - await navigator.share({ - title: "plugin-babylon Quick Start", - text: shareText, - url: BABYLON_QUICKSTART_URL, - }); - method = "native"; - setShareBabylonLabel("Shared"); - toast.success("Babylon quick start shared"); - } else { - await navigator.clipboard.writeText(BABYLON_QUICKSTART_URL); - method = "clipboard"; - setShareBabylonLabel("Link Copied"); - toast.success("Babylon quick-start link copied"); - } - } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - return; - } - try { - await navigator.clipboard.writeText(BABYLON_QUICKSTART_URL); - method = "clipboard"; - setShareBabylonLabel("Link Copied"); - toast.success("Babylon quick-start link copied"); - } catch { - toast.error("Unable to share right now"); - return; - } - } - - if (method) { - posthog.capture("agent_dev_babylon_quickstart_shared", { - platform, - method, - }); - clearShareBabylonFeedbackLater(); - } - }, [clearShareBabylonFeedbackLater, platform]); - - useEffect(() => { - return () => { - if (shareBabylonTimerRef.current) { - window.clearTimeout(shareBabylonTimerRef.current); - } - }; - }, []); - - const selectedDeckInfo = starterDecks?.find((d) => d.deckCode === selectedDeck); - - return ( -
-
- - {/* Decorative scatter behind content */} - - -
- {/* Header */} -
- -

- Connect your AI agent to play LunchTable -

- - {/* Speech bubble call-to-action */} -
- - - Pick your side! - - -
-
- - {/* Step 1: Choose platform */} -
-

- 1 — Choose your agent platform -

-
- handlePlatformSelect("milaidy")} - /> - handlePlatformSelect("openclawd")} - /> -
-
- - {/* Step 2: Register agent */} - {platform && !registeredKey && ( -
-

- 2 — Register your agent -

- -
-
-
-
- - setAgentName(e.target.value)} - placeholder="MyAgent_001" - maxLength={50} - className="w-full px-4 py-2.5 bg-white text-[#121212] text-sm font-mono border-2 border-[#121212] focus:outline-none focus:border-[#ffcc00] transition-colors" - style={{ boxShadow: "2px 2px 0px rgba(0,0,0,0.1)" }} - disabled={registering} - /> -
- - {registerError && ( -

{registerError}

- )} - - -
-
-
- )} - - {/* Step 3: API Key + Plugin Setup */} - {registeredKey && ( -
-

- 3 — Save your credentials -

- -
-
-
- {/* Warning */} -
-

- Save this key now — it cannot be retrieved again -

-
- - {/* API Key */} -
- -
- - {registeredKey} - - -
-
- - {/* API URL */} -
- - - {convexSiteUrl} - -
- - {/* Soundtrack API URL */} -
- - - {soundtrackApiUrl} - -
- - {/* Install command */} -
-

- Install the plugin -

- - {platform === "milaidy" - ? "milady plugins add @lunchtable/app-lunchtable" - : "npm install @lunchtable/plugin-ltcg"} - -
- - {/* Env setup */} -
-

- Add to your .env -

- -{`LTCG_API_URL=${convexSiteUrl} -LTCG_API_KEY=${registeredKey} -LTCG_SOUNDTRACK_API_URL=${soundtrackApiUrl}`} - -
-
-
-
- )} - - {/* Step 4: Connect */} - {platform && registeredKey && ( -
-

- 4 — Connect to your agent -

- -
-
- -
-
- - setApiUrl(e.target.value)} - placeholder={PLATFORM_INFO[platform].defaultApi} - className="w-full px-4 py-2.5 bg-white text-[#121212] text-sm font-mono border-2 border-[#121212] focus:outline-none focus:border-[#ffcc00] transition-colors" - style={{ boxShadow: "2px 2px 0px rgba(0,0,0,0.1)" }} - /> -
- -
- - setApiToken(e.target.value)} - placeholder="Bearer token" - className="w-full px-4 py-2.5 bg-white text-[#121212] text-sm font-mono border-2 border-[#121212] focus:outline-none focus:border-[#ffcc00] transition-colors" - style={{ boxShadow: "2px 2px 0px rgba(0,0,0,0.1)" }} - /> -
- -
- - - {connStatus !== "disconnected" && ( -
- - - {connMsg} - -
- )} -
-
-
-
- )} - - {/* Step 5: Assign starter deck */} - {connStatus === "connected" && ( -
-

- 5 — Assign a starter deck -

- - {!starterDecks ? ( -
-
-
- ) : ( - <> -
- {starterDecks.map((deck) => { - const color = ARCHETYPE_COLORS[deck.archetype] ?? "#666"; - const emoji = ARCHETYPE_EMOJI[deck.archetype] ?? "\u{1F0CF}"; - const isSelected = selectedDeck === deck.deckCode; - - return ( - - ); - })} -
- - {deckError && ( -

- {deckError} -

- )} - -
- {deckAssigned && selectedDeckInfo ? ( -
- - {selectedDeckInfo.name} assigned -
- ) : ( - - )} -
- - )} -
- )} - - {/* Step 6: Extra panels (only after deck assigned) */} - {deckAssigned && connStatus === "connected" && ( -
-

- 6 — Agent status -

- -
- {/* Streaming */} - -

- Stream matches live on retake.tv -

-
- - - retake.tv migrating to Solana — coming soon - -
- - Learn more → - -
- - {/* Match History */} - -

- Your agent's match history -

-
- -

- No matches played yet -

-
-
-
-
- )} - - {/* Docs link */} - {platform && ( -
- - )} -
- - -
- ); -} diff --git a/apps/web-tanstack/src/app/pages/AgentLobby.test.ts b/apps/web-tanstack/src/app/pages/AgentLobby.test.ts new file mode 100644 index 0000000..e945538 --- /dev/null +++ b/apps/web-tanstack/src/app/pages/AgentLobby.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync( + path.join(process.cwd(), "apps/web-tanstack/src/app/pages/AgentLobby.tsx"), + "utf8", +); + +describe("AgentLobby control surface", () => { + it("exposes full agent-vs-agent lobby controls", () => { + expect(source).toContain("/api/agent/register"); + expect(source).toContain("/api/agent/game/pvp/create"); + expect(source).toContain("/api/agent/game/pvp/cancel"); + expect(source).toContain("/api/agent/game/join"); + expect(source).toContain("/api/agent/story/next-stage"); + expect(source).toContain("/api/agent/game/start"); + }); + + it("renders onboarding and core control sections", () => { + expect(source).toContain("Step 1 (Recommended): Create Agent Key"); + expect(source).toContain("Step 3: Boot Runtime (OpenClawd + milady/elizaOS parity)"); + expect(source).toContain("Copy Solana x402 Env"); + expect(source).toContain("Wallet safety: keep Solana private keys only in runtime secret stores."); + expect(source).toContain("Agent Onboarding Complete"); + expect(source).toContain("Active Story Arenas"); + expect(source).toContain("Agent Lobby Chat"); + expect(source).toContain("Create PvP Lobby"); + expect(source).toContain("Start Next Story"); + }); +}); diff --git a/apps/web-tanstack/src/app/pages/AgentLobby.tsx b/apps/web-tanstack/src/app/pages/AgentLobby.tsx new file mode 100644 index 0000000..e7f16d1 --- /dev/null +++ b/apps/web-tanstack/src/app/pages/AgentLobby.tsx @@ -0,0 +1,1115 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { motion } from "framer-motion"; +import { AgentOverlayNav } from "@/components/layout/AgentOverlayNav"; +import { AmbientBackground } from "@/components/ui/AmbientBackground"; +import { LANDING_BG, MENU_TEXTURE } from "@/lib/blobUrls"; +import { buildStreamOverlayUrl } from "@/lib/streamOverlayParams"; +import { useAgentApiSession } from "@/hooks/auth/useAgentApiSession"; + +type LobbyMessage = { + _id: string; + userId: string; + senderName: string; + text: string; + source: "agent" | "retake" | "system"; + createdAt: number; +}; + +type LobbySummary = { + matchId: string; + hostUserId: string; + hostUsername: string; + visibility: "public" | "private"; + joinCode: string | null; + status: "waiting" | "active"; + createdAt: number; + activatedAt: number | null; + pongEnabled: boolean; + redemptionEnabled: boolean; + retake: { + hasRetakeAccount: boolean; + pipelineEnabled: boolean; + agentName: string | null; + tokenAddress: string | null; + tokenTicker: string | null; + streamUrl: string | null; + }; +}; + +type LobbySnapshot = { + currentUser: { + userId: string; + username: string; + walletAddress: string | null; + hasRetakeAccount: boolean; + pipelineEnabled: boolean; + agentName: string | null; + tokenAddress: string | null; + tokenTicker: string | null; + streamUrl: string | null; + }; + openLobbies: LobbySummary[]; + activeStoryMatches: Array<{ + matchId: string; + chapterId: string; + stageNumber: number; + playerUserId: string; + playerUsername: string; + status: "waiting" | "active"; + retake: LobbySummary["retake"]; + }>; + messages: LobbyMessage[]; +}; + +type StoryNextStageResponse = { + done: boolean; + chapterId?: string; + stageNumber?: number; +}; + +type CreatedPvpLobby = { + matchId: string; + visibility: "public"; + joinCode: null; + status: "waiting"; + createdAt: number; +}; + +type RegisteredAgentResponse = { + id: string; + userId: string; + name: string; + apiKey: string; + apiKeyPrefix: string; +}; + +const sourcePillClass: Record = { + agent: "bg-[#121212] text-white", + retake: "bg-[#ffcc00] text-[#121212]", + system: "bg-[#1d4ed8] text-white", +}; + +function formatTimestamp(ts: number) { + const date = new Date(ts); + return date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); +} + +async function agentFetch(args: { + apiBaseUrl: string; + apiKey?: string | null; + path: string; + method?: "GET" | "POST"; + body?: unknown; +}): Promise { + const headers: Record = { + "Content-Type": "application/json", + }; + if (args.apiKey) { + headers.Authorization = `Bearer ${args.apiKey}`; + } + + const response = await fetch(`${args.apiBaseUrl}${args.path}`, { + method: args.method ?? "GET", + headers, + body: args.body ? JSON.stringify(args.body) : undefined, + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + const reason = + typeof payload?.error === "string" + ? payload.error + : `Request failed (${response.status})`; + throw new Error(reason); + } + + return payload as T; +} + +function randomAgentName() { + return `ltcg-agent-${Math.random().toString(36).slice(2, 8)}`; +} + +function buildOpenClawdEnvSnippet(apiUrl: string, key: string) { + return [ + `export LTCG_API_URL="${apiUrl}"`, + `export LTCG_API_KEY="${key}"`, + "export LTCG_RUNTIME=openclawd", + ].join("\n"); +} + +function buildElizaEnvSnippet(apiUrl: string, key: string) { + return [ + `export LTCG_API_URL="${apiUrl}"`, + `export LTCG_API_KEY="${key}"`, + "export LTCG_RUNTIME=milady-elizaos", + ].join("\n"); +} + +function buildX402SolanaEnvSnippet() { + return [ + "export LTCG_X402_ENABLED=true", + "export LTCG_X402_SOLANA_NETWORK=mainnet", + "export LTCG_X402_SOLANA_PRIVATE_KEY_B58=\"\"", + "# Optional: export LTCG_X402_SOLANA_RPC_URL=\"https://your-rpc.example\"", + ].join("\n"); +} + +function buildSmokeTestSnippet(apiUrl: string, key: string) { + return `curl -s -H "Authorization: Bearer ${key}" "${apiUrl}/api/agent/me"`; +} + +export function AgentLobby() { + const { + apiBaseUrl, + apiKey, + agent, + status, + error: sessionError, + setApiKey, + clearSession, + refresh, + } = useAgentApiSession(); + + const [draft, setDraft] = useState(""); + const [manualApiKey, setManualApiKey] = useState(""); + const [newAgentName, setNewAgentName] = useState(() => randomAgentName()); + const [createdApiKey, setCreatedApiKey] = useState(null); + const [copiedLabel, setCopiedLabel] = useState(""); + const [busyKey, setBusyKey] = useState(null); + const [error, setError] = useState(""); + const [snapshot, setSnapshot] = useState(null); + const [loadingSnapshot, setLoadingSnapshot] = useState(false); + const [lastMatchId, setLastMatchId] = useState(null); + const [lastSeat, setLastSeat] = useState<"host" | "away" | null>(null); + + const onboardingApiUrl = + apiBaseUrl?.replace(/\/$/, "") ?? "https://your-convex-site.convex.site"; + const onboardingApiKey = + (createdApiKey ?? apiKey ?? manualApiKey.trim()) || "ltcg_your_agent_key"; + + const openClawdEnvSnippet = useMemo( + () => buildOpenClawdEnvSnippet(onboardingApiUrl, onboardingApiKey), + [onboardingApiKey, onboardingApiUrl], + ); + const elizaEnvSnippet = useMemo( + () => buildElizaEnvSnippet(onboardingApiUrl, onboardingApiKey), + [onboardingApiKey, onboardingApiUrl], + ); + const x402SolanaSnippet = useMemo(() => buildX402SolanaEnvSnippet(), []); + const smokeTestSnippet = useMemo( + () => buildSmokeTestSnippet(onboardingApiUrl, onboardingApiKey), + [onboardingApiKey, onboardingApiUrl], + ); + + useEffect(() => { + if (!copiedLabel) return; + const timeout = window.setTimeout(() => setCopiedLabel(""), 2200); + return () => window.clearTimeout(timeout); + }, [copiedLabel]); + + const loadSnapshot = useCallback(async () => { + if (!apiBaseUrl || !apiKey || status !== "connected") { + setSnapshot(null); + return; + } + + const next = await agentFetch({ + apiBaseUrl, + apiKey, + path: "/api/agent/lobby/snapshot?limit=80", + }); + setSnapshot(next); + }, [apiBaseUrl, apiKey, status]); + + useEffect(() => { + let active = true; + + if (status !== "connected") { + setSnapshot(null); + setLoadingSnapshot(false); + return; + } + + setLoadingSnapshot(true); + loadSnapshot() + .catch((nextError) => { + if (!active) return; + setError(nextError instanceof Error ? nextError.message : "Failed to load lobby snapshot."); + }) + .finally(() => { + if (active) setLoadingSnapshot(false); + }); + + const interval = window.setInterval(() => { + loadSnapshot().catch(() => { + // Keep polling resilient; surfaced by next manual interaction if needed. + }); + }, 8000); + + return () => { + active = false; + window.clearInterval(interval); + }; + }, [loadSnapshot, status]); + + const openLobbies = useMemo(() => { + if (!snapshot?.openLobbies) return []; + return [...snapshot.openLobbies].sort((a, b) => b.createdAt - a.createdAt); + }, [snapshot?.openLobbies]); + + const myWaitingLobbies = useMemo(() => { + if (!snapshot) return []; + return openLobbies.filter( + (lobby) => + lobby.hostUserId === snapshot.currentUser.userId && + lobby.status === "waiting", + ); + }, [openLobbies, snapshot]); + + const activeStoryMatches = useMemo(() => { + if (!snapshot?.activeStoryMatches) return []; + return [...snapshot.activeStoryMatches].sort((a, b) => b.stageNumber - a.stageNumber); + }, [snapshot?.activeStoryMatches]); + + const handleConnect = useCallback(() => { + setError(""); + setApiKey(manualApiKey); + }, [manualApiKey, setApiKey]); + + const handleCopySnippet = useCallback(async (label: string, text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopiedLabel(label); + } catch { + setCopiedLabel("Clipboard unavailable"); + } + }, []); + + const handleRegisterAndConnect = useCallback(async () => { + if (!apiBaseUrl) { + setError("Missing API base URL for registration."); + return; + } + + setError(""); + setBusyKey("register"); + try { + const name = newAgentName.trim() || randomAgentName(); + const registration = await agentFetch({ + apiBaseUrl, + path: "/api/agent/register", + method: "POST", + body: { name }, + }); + + if (!registration.apiKey) { + throw new Error("Registration did not return an API key."); + } + + setCreatedApiKey(registration.apiKey); + setManualApiKey(registration.apiKey); + setApiKey(registration.apiKey); + setNewAgentName(randomAgentName()); + } catch (nextError) { + setError( + nextError instanceof Error + ? nextError.message + : "Failed to register new agent session.", + ); + } finally { + setBusyKey(null); + } + }, [apiBaseUrl, newAgentName, setApiKey]); + + const handleSendMessage = useCallback(async () => { + if (!apiBaseUrl || !apiKey) return; + const text = draft.trim(); + if (!text) return; + + setError(""); + setBusyKey("send"); + try { + await agentFetch({ + apiBaseUrl, + apiKey, + path: "/api/agent/lobby/chat", + method: "POST", + body: { text, source: "agent" }, + }); + setDraft(""); + await loadSnapshot(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Failed to send lobby chat message."); + } finally { + setBusyKey(null); + } + }, [apiBaseUrl, apiKey, draft, loadSnapshot]); + + const handleTogglePipeline = useCallback(async () => { + if (!apiBaseUrl || !apiKey || !snapshot) return; + setError(""); + setBusyKey("pipeline"); + try { + await agentFetch({ + apiBaseUrl, + apiKey, + path: "/api/agent/retake/pipeline", + method: "POST", + body: { enabled: !snapshot.currentUser.pipelineEnabled }, + }); + await loadSnapshot(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Failed to toggle Retake pipeline."); + } finally { + setBusyKey(null); + } + }, [apiBaseUrl, apiKey, loadSnapshot, snapshot]); + + const handleCreatePvpLobby = useCallback(async () => { + if (!apiBaseUrl || !apiKey) return; + setError(""); + setBusyKey("create"); + try { + const result = await agentFetch({ + apiBaseUrl, + apiKey, + path: "/api/agent/game/pvp/create", + method: "POST", + }); + setLastMatchId(result.matchId); + setLastSeat("host"); + await loadSnapshot(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Failed to create PvP lobby."); + } finally { + setBusyKey(null); + } + }, [apiBaseUrl, apiKey, loadSnapshot]); + + const handleCancelLobby = useCallback( + async (matchId: string) => { + if (!apiBaseUrl || !apiKey) return; + setError(""); + setBusyKey(`cancel:${matchId}`); + try { + await agentFetch({ + apiBaseUrl, + apiKey, + path: "/api/agent/game/pvp/cancel", + method: "POST", + body: { matchId }, + }); + if (lastMatchId === matchId) { + setLastMatchId(null); + setLastSeat(null); + } + await loadSnapshot(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Failed to cancel PvP lobby."); + } finally { + setBusyKey(null); + } + }, + [apiBaseUrl, apiKey, lastMatchId, loadSnapshot], + ); + + const handleStartNextStoryMatch = useCallback(async () => { + if (!apiBaseUrl || !apiKey) return; + setError(""); + setBusyKey("story"); + try { + const nextStage = await agentFetch({ + apiBaseUrl, + apiKey, + path: "/api/agent/story/next-stage", + }); + + if (nextStage.done || !nextStage.chapterId || !nextStage.stageNumber) { + throw new Error("Story mode is complete for this agent."); + } + + const result = await agentFetch<{ matchId: string }>({ + apiBaseUrl, + apiKey, + path: "/api/agent/game/start", + method: "POST", + body: { + chapterId: nextStage.chapterId, + stageNumber: nextStage.stageNumber, + }, + }); + + setLastMatchId(result.matchId); + setLastSeat("host"); + await loadSnapshot(); + } catch (nextError) { + setError( + nextError instanceof Error ? nextError.message : "Failed to start next story match.", + ); + } finally { + setBusyKey(null); + } + }, [apiBaseUrl, apiKey, loadSnapshot]); + + const handleJoinLobby = useCallback( + async (matchId: string) => { + if (!apiBaseUrl || !apiKey) return; + setError(""); + setBusyKey(`join:${matchId}`); + try { + await agentFetch({ + apiBaseUrl, + apiKey, + path: "/api/agent/game/join", + method: "POST", + body: { matchId }, + }); + setLastMatchId(matchId); + setLastSeat("away"); + await loadSnapshot(); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : "Failed to join lobby."); + } finally { + setBusyKey(null); + } + }, + [apiBaseUrl, apiKey, loadSnapshot], + ); + + const lastHostOverlay = useMemo( + () => (lastMatchId ? buildStreamOverlayUrl({ matchId: lastMatchId, seat: "host" }) : null), + [lastMatchId], + ); + const lastAwayOverlay = useMemo( + () => (lastMatchId ? buildStreamOverlayUrl({ matchId: lastMatchId, seat: "away" }) : null), + [lastMatchId], + ); + + if (status !== "connected") { + return ( +
+
+ + +
+
+ + Agent Control Lobby + + + API-key-first control surface for Story, PvP, chat, and Retake pipeline. + +
+ +
+
+
+
+

+ Step 1 (Recommended): Create Agent Key +

+

+ One click creates an agent account, issues an ltcg_ key, and connects this lobby. +

+
+ setNewAgentName(event.target.value)} + placeholder="agent name" + className="flex-1 border-2 border-[#121212] bg-white px-3 py-2 text-sm" + /> + +
+ {createdApiKey && ( +
+

+ New API key (save it now) +

+ + +
+ )} +
+ +
+

+ Step 2: Connect Existing Key +

+

+ Pass ?apiKey=ltcg_..., postMessage LTCG_AUTH, + or paste the key below. +

+
+ setManualApiKey(event.target.value)} + placeholder="ltcg_..." + className="flex-1 border-2 border-[#121212] bg-white px-3 py-2 text-sm font-mono" + /> + + +
+
+ +
+

+ Step 3: Boot Runtime (OpenClawd + milady/elizaOS parity) +

+
+ + + + +
+

{smokeTestSnippet}

+

+ Wallet safety: keep Solana private keys only in runtime secret stores. Never paste private keys into chat or browser forms. +

+ {copiedLabel && ( +

+ {copiedLabel} +

+ )} +
+ + {sessionError && ( +

{sessionError}

+ )} + {error &&

{error}

} +
+
+
+ + +
+ ); + } + + return ( +
+
+ + +
+
+ + Agent Chat Lobby + + + OpenClawd and milady/elizaOS parity: shared lobby, shared pipeline, shared overlays. + +
+ +
+
+
+

+ Agent Onboarding Complete +

+

+ Next fastest path: create/join lobby, run story or PvP actions, and open spectator overlays. +

+
+ + + + + {copiedLabel && ( + + {copiedLabel} + + )} +
+

+ Solana wallet keys stay runtime-side. Lobby/API only sees wallet addresses and signed x402 requests. +

+
+
+ + {loadingSnapshot ? ( +
+
+
+ ) : !snapshot ? ( +

+ Unable to load lobby snapshot. +

+ ) : ( +
+
+
+
+
+

+ Logged In As {snapshot.currentUser.username} +

+

+ Agent: {agent?.name ?? "unknown"} ({agent?.apiKeyPrefix ?? "n/a"}) +

+

+ Wallet: {snapshot.currentUser.walletAddress ?? "No wallet detected"} +

+ {snapshot.currentUser.hasRetakeAccount ? ( +

+ Retake: {snapshot.currentUser.agentName ?? "linked"} + {snapshot.currentUser.tokenTicker + ? ` • ${snapshot.currentUser.tokenTicker}` + : ""} +

+ ) : ( +

+ Retake account not linked yet. Use REGISTER_RETAKE_STREAM from your agent runtime. +

+ )} +
+ +
+ + + {myWaitingLobbies[0] && ( + + )} + {snapshot.currentUser.streamUrl && ( + + Open Retake Channel + + )} + + +
+
+ {lastMatchId && ( +
+ + Last match: {lastMatchId} ({lastSeat ?? "unknown"}) + + {lastHostOverlay && ( + + Host Overlay + + )} + {lastAwayOverlay && ( + + Away Overlay + + )} +
+ )} +
+ + {error && ( +

{error}

+ )} + +
+
+
+
+

+ Open Agent Lobbies +

+ {openLobbies.length === 0 ? ( +

+ No waiting or active public lobbies right now. +

+ ) : ( +
+ {openLobbies.map((lobby) => { + const hostOverlay = buildStreamOverlayUrl({ matchId: lobby.matchId, seat: "host" }); + const awayOverlay = buildStreamOverlayUrl({ matchId: lobby.matchId, seat: "away" }); + return ( +
+
+
+

+ {lobby.hostUsername} +

+

{lobby.matchId}

+
+ + {lobby.status} + +
+ +
+ + Host Overlay + + + Away Overlay + + {lobby.status === "waiting" && + lobby.hostUserId !== snapshot.currentUser.userId && ( + + )} + {lobby.retake.pipelineEnabled && lobby.retake.streamUrl && ( + + Retake Stream + + )} +
+ +

+ Pipeline: {lobby.retake.pipelineEnabled ? "Retake enabled" : "Overlay only"} + {lobby.joinCode ? ` • Code ${lobby.joinCode}` : ""} +

+
+ ); + })} +
+ )} + +
+

+ Active Story Arenas +

+ {activeStoryMatches.length === 0 ? ( +

+ No active story runs right now. +

+ ) : ( +
+ {activeStoryMatches.map((storyMatch) => { + const hostOverlay = buildStreamOverlayUrl({ + matchId: storyMatch.matchId, + seat: "host", + }); + return ( +
+
+
+

+ {storyMatch.playerUsername} +

+

+ Chapter {storyMatch.chapterId} • Stage {storyMatch.stageNumber} +

+

+ {storyMatch.matchId} +

+
+ + {storyMatch.status} + +
+
+ + Story Overlay + + {storyMatch.retake.pipelineEnabled && + storyMatch.retake.streamUrl && ( + + Retake Stream + + )} +
+
+ ); + })} +
+ )} +
+
+
+ +
+
+
+

+ Agent Lobby Chat +

+ +
+ {snapshot.messages.length === 0 ? ( +

+ No messages yet. +

+ ) : ( + snapshot.messages.map((message) => ( +
+
+

+ {message.senderName} +

+ + {message.source} + +
+

{message.text}

+

{formatTimestamp(message.createdAt)}

+
+ )) + )} +
+ +
+ setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleSendMessage(); + } + }} + placeholder="Say something to agent operators..." + className="flex-1 border-2 border-[#121212] bg-white px-3 py-2 text-sm" + /> + +
+
+
+
+
+ )} +
+ + +
+ ); +} diff --git a/apps/web-tanstack/src/app/pages/BlobDemo.tsx b/apps/web-tanstack/src/app/pages/BlobDemo.tsx deleted file mode 100644 index fef8293..0000000 --- a/apps/web-tanstack/src/app/pages/BlobDemo.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useState } from 'react'; -import { ImageUpload } from '@/components/ImageUpload'; -import { toast } from 'sonner'; - -export default function BlobUploadDemo() { - const [uploadedUrls, setUploadedUrls] = useState([]); - - const handleUploadComplete = (result: { url: string }) => { - setUploadedUrls(prev => [...prev, result.url]); - toast.success('Upload complete!'); - }; - - return ( -
-
-
-

- Vercel Blob Upload Demo -

-

- Test image uploads to Vercel Blob storage -

-
- - {/* Upload Component */} -
-

- Upload Image -

- -
- - {/* Uploaded Images */} - {uploadedUrls.length > 0 && ( -
-

- Uploaded Images -

-
- {uploadedUrls.map((url, index) => ( -
- {`Upload -

{url}

-
- ))} -
-
- )} - - {/* Instructions */} -
-

- Setup Required -

-
    -
  1. Run vercel link to connect project
  2. -
  3. Run vercel storage add blob
  4. -
  5. Run vercel env pull to get token
  6. -
  7. Restart dev server
  8. -
-

- See docs/VERCEL_BLOB_SETUP.md for full instructions -

-
-
-
- ); -} diff --git a/apps/web-tanstack/src/app/pages/Home.tsx b/apps/web-tanstack/src/app/pages/Home.tsx deleted file mode 100644 index f8576a2..0000000 --- a/apps/web-tanstack/src/app/pages/Home.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import { useCallback } from "react"; -import { useNavigate } from "@/router/react-router"; -import { usePrivy } from "@privy-io/react-auth"; -import { motion } from "framer-motion"; -import { useIframeMode } from "@/hooks/useIframeMode"; -import { usePostLoginRedirect, storeRedirect } from "@/hooks/auth/usePostLoginRedirect"; -import { TrayNav } from "@/components/layout/TrayNav"; -import { PRIVY_ENABLED } from "@/lib/auth/privyEnv"; -import { AmbientBackground } from "@/components/ui/AmbientBackground"; -import { useCardTilt } from "@/hooks/useCardTilt"; -import { SpeechBubble } from "@/components/ui/SpeechBubble"; -import { SpeedLines } from "@/components/ui/SpeedLines"; -import { DecorativeScatter } from "@/components/ui/DecorativeScatter"; -import { ComicImpactText } from "@/components/ui/ComicImpactText"; -import { - INK_FRAME, LANDING_BG, DECO_PILLS, TITLE, - STORY_BG, COLLECTION_BG, DECK_BG, WATCH_BG, PVP_BG, - TAPE, CIGGARETTE_TRAY, -} from "@/lib/blobUrls"; - -const panelVariants = { - hidden: { opacity: 0, y: 24, scale: 0.96 }, - visible: { opacity: 1, y: 0, scale: 1, transition: { type: "spring" as const, stiffness: 300, damping: 24 } }, -}; - -const containerVariants = { - hidden: {}, - visible: { transition: { staggerChildren: 0.12, delayChildren: 0.4 } }, -}; - -function Panel({ - title, - subtitle, - bgImage, - bgContain, - children, - onClick, - impactWord, -}: { - title: string; - subtitle: string; - bgImage?: string; - bgContain?: boolean; - children?: React.ReactNode; - onClick?: () => void; - impactWord?: string; -}) { - const { tiltStyle, onMouseMove, onMouseLeave } = useCardTilt({ maxTilt: 6 }); - - return ( -
- - {/* Holographic gradient overlay — visible on hover */} -
- - - {bgImage ? ( -
- ) : ( -
- )} -
- {bgImage && ( -
- )} - - {/* Impact text on hover */} - {impactWord && ( -
- -
- )} - -
- {children} -

- {title} -

-

- {subtitle} -

-
- -
- ); -} - -export function Home() { - const { isEmbedded } = useIframeMode(); - const navigate = useNavigate(); - const { authenticated, login } = PRIVY_ENABLED - ? usePrivy() - : { authenticated: false, login: () => { } }; - - // After Privy login returns to Home, auto-navigate to the saved destination - usePostLoginRedirect(); - - const goTo = useCallback( - (path: string, requiresAuth: boolean) => { - if (requiresAuth && !authenticated) { - storeRedirect(path); - login(); - return; - } - navigate(path); - }, - [authenticated, login, navigate], - ); - - return ( -
-
- - {/* Ambient floating ink particles */} - - - {/* Decorative pill bottle */} - - - {/* Floating decorations — tape + cigarette tray scattered behind panels */} - - - {/* Header */} -
- {/* Speed lines behind the first panel area */} -
- - {/* Dramatic bounce-drop entrance for title */} - -
- {/* SpeechBubble subtitle */} - - - - School of Hard Knocks - - - -
- - {/* Comic panels grid */} - - goTo("/story", true)} - impactWord="FIGHT!" - > -
-
- - goTo("/collection", true)} - impactWord="COLLECT!" - > -
-
- - goTo("/decks", true)} - impactWord="BUILD!" - > -
-
- - goTo("/watch", false)} - impactWord="WATCH!" - > -
-
- - goTo("/pvp", true)} - impactWord="DUEL!" - > -
-
- -
- - {isEmbedded && ( -

- Running inside milaidy -

- )} - - -
- ); -} diff --git a/apps/web-tanstack/src/app/pages/Onboarding.tsx b/apps/web-tanstack/src/app/pages/Onboarding.tsx index f26f178..e6caf0e 100644 --- a/apps/web-tanstack/src/app/pages/Onboarding.tsx +++ b/apps/web-tanstack/src/app/pages/Onboarding.tsx @@ -7,6 +7,7 @@ import { apiAny, useConvexMutation, useConvexQuery } from "@/lib/convexHelpers"; import { useUserSync } from "@/hooks/auth/useUserSync"; import { consumeRedirect } from "@/hooks/auth/usePostLoginRedirect"; import { LANDING_BG } from "@/lib/blobUrls"; +import { registerAgent } from "@/lib/retake"; import { DEFAULT_SIGNUP_AVATAR_PATH, SIGNUP_AVATAR_OPTIONS, @@ -25,6 +26,11 @@ interface StarterDeck { cardCount: number; } +type CurrentUserProfile = { + username: string; + walletAddress?: string; +}; + const ARCHETYPE_COLORS: Record = { dropouts: "#e53e3e", preps: "#3182ce", @@ -51,23 +57,53 @@ export function Onboarding() { const { isAuthenticated } = useConvexAuth(); const setUsernameMutation = useConvexMutation(apiAny.auth.setUsername); + const setRetakeChoiceMutation = useConvexMutation(apiAny.auth.setRetakeOnboardingChoice); + const linkRetakeAccountMutation = useConvexMutation(apiAny.auth.linkRetakeAccount); const setAvatarPathMutation = useConvexMutation(apiAny.auth.setAvatarPath); const selectStarterDeckMutation = useConvexMutation(apiAny.game.selectStarterDeck); + const currentUser = useConvexQuery(apiAny.auth.currentUser, isAuthenticated ? {} : "skip") as + | CurrentUserProfile + | null + | undefined; const starterDecks = useConvexQuery(apiAny.game.getStarterDecks, isAuthenticated ? {} : "skip") as | StarterDeck[] | undefined; // Determine which step we're on based on onboarding status const needsUsername = onboardingStatus && !onboardingStatus.hasUsername; + const retakeResolved = Boolean( + onboardingStatus?.hasRetakeChoice && + (!onboardingStatus.wantsRetake || onboardingStatus.hasRetakeAccount), + ); + const needsRetake = + onboardingStatus && + onboardingStatus.hasUsername && + !retakeResolved; const needsAvatar = onboardingStatus && onboardingStatus.hasUsername && + retakeResolved && !onboardingStatus.hasAvatar; const needsDeck = onboardingStatus && onboardingStatus.hasUsername && + retakeResolved && onboardingStatus.hasAvatar && !onboardingStatus.hasStarterDeck; + const stepCopy = needsUsername + ? "Step 1 of 4: Choose your name" + : needsRetake + ? "Step 2 of 4: Link optional Retake account" + : needsAvatar + ? "Step 3 of 4: Pick your avatar" + : "Step 4 of 4: Pick your deck"; + const progressWidth = needsUsername + ? "25%" + : needsRetake + ? "50%" + : needsAvatar + ? "75%" + : "100%"; const handleUsernameComplete = useCallback(() => { // onboardingStatus will reactively update @@ -77,6 +113,10 @@ export function Onboarding() { // onboardingStatus will reactively update }, []); + const handleRetakeComplete = useCallback(() => { + // onboardingStatus will reactively update + }, []); + const handleDeckComplete = useCallback(() => { navigate(consumeRedirect() ?? "/"); }, [navigate]); @@ -91,7 +131,12 @@ export function Onboarding() { } // Already complete — redirect to saved destination or home - if (onboardingStatus.hasUsername && onboardingStatus.hasStarterDeck) { + if ( + onboardingStatus.hasUsername && + retakeResolved && + onboardingStatus.hasAvatar && + onboardingStatus.hasStarterDeck + ) { navigate(consumeRedirect() ?? "/", { replace: true }); return null; } @@ -123,11 +168,7 @@ export function Onboarding() { animate={{ opacity: 1 }} transition={{ delay: 0.2 }} > - {needsUsername - ? "Step 1 of 3: Choose your name" - : needsAvatar - ? "Step 2 of 3: Pick your avatar" - : "Step 3 of 3: Pick your deck"} + {stepCopy}
@@ -137,7 +178,7 @@ export function Onboarding() {
@@ -158,6 +199,23 @@ export function Onboarding() { /> )} + {needsRetake && ( + + + + )} {needsAvatar && ( Promise<{ + success: boolean; + choice: "declined" | "accepted"; + }>; + linkRetakeAccountMutation: (args: { + agentId: string; + userDbId: string; + agentName: string; + walletAddress: string; + tokenAddress: string; + tokenTicker: string; + }) => Promise<{ success: boolean; streamUrl: string }>; + onComplete: () => void; +}) { + const [agentName, setAgentName] = useState(() => username.replace(/\s+/g, "_").slice(0, 24)); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const handleSkip = async () => { + setSubmitting(true); + setError(""); + try { + await setRetakeChoiceMutation({ choice: "declined" }); + onComplete(); + } catch (err: any) { + Sentry.captureException(err); + setError(err.message ?? "Failed to skip Retake setup."); + } finally { + setSubmitting(false); + } + }; + + const handleCreate = async () => { + if (!walletAddress) { + setError("No wallet found. Sign in again with Phantom, Solflare, or Backpack."); + return; + } + const normalizedName = agentName.trim().replace(/\s+/g, "_"); + if (normalizedName.length < 3) { + setError("Retake name must be at least 3 characters."); + return; + } + + setSubmitting(true); + setError(""); + try { + const imageUrl = + typeof window !== "undefined" + ? `${window.location.origin}/favicon.ico` + : "https://lunchtable.app/favicon.ico"; + const registration = await registerAgent({ + agent_name: normalizedName, + agent_description: `LunchTable agent streamer for ${username}.`, + image_url: imageUrl, + wallet_address: walletAddress, + }); + + if (!registration) { + throw new Error("Retake registration failed. Please try again."); + } + + await linkRetakeAccountMutation({ + agentId: registration.agent_id, + userDbId: registration.userDbId, + agentName: registration.agent_name, + walletAddress: registration.wallet_address, + tokenAddress: registration.token_address, + tokenTicker: registration.token_ticker, + }); + onComplete(); + } catch (err: any) { + Sentry.captureException(err); + setError(err.message ?? "Failed to link Retake account."); + } finally { + setSubmitting(false); + } + }; + + return ( +
+

+ RETAKE PIPELINE (OPTIONAL) +

+

+ Use your same wallet to create a Retake identity, stream matches, and keep overlay integrity end-to-end. +

+ +
+
+ + +
+
+ + setAgentName(event.target.value)} + className="mt-1 w-full border-2 border-[#121212] bg-white px-3 py-2 text-sm font-bold" + maxLength={32} + disabled={submitting} + /> +
+
+ + {error &&

{error}

} + +
+ + +
+
+ ); +} + +// ── Step 3: Avatar Selection ────────────────────────────────────── function AvatarSelectionStep({ setAvatarPathMutation, @@ -361,7 +574,7 @@ function AvatarSelectionStep({ ); } -// ── Step 3: Deck Selection ──────────────────────────────────────── +// ── Step 4: Deck Selection ──────────────────────────────────────── interface DeckSelectionStepProps { decks: StarterDeck[] | undefined; diff --git a/apps/web-tanstack/src/app/pages/Pvp.tsx b/apps/web-tanstack/src/app/pages/Pvp.tsx index b833e01..033f646 100644 --- a/apps/web-tanstack/src/app/pages/Pvp.tsx +++ b/apps/web-tanstack/src/app/pages/Pvp.tsx @@ -2,9 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "@/router/react-router"; import { motion, AnimatePresence } from "framer-motion"; import { apiAny, useConvexMutation, useConvexQuery } from "@/lib/convexHelpers"; -import { TrayNav } from "@/components/layout/TrayNav"; +import { AgentOverlayNav } from "@/components/layout/AgentOverlayNav"; import { AmbientBackground } from "@/components/ui/AmbientBackground"; import { LANDING_BG, MENU_TEXTURE } from "@/lib/blobUrls"; +import { buildStreamOverlayUrl } from "@/lib/streamOverlayParams"; type PvpLobbySummary = { matchId: string; @@ -98,6 +99,14 @@ export function Pvp() { () => [...(openLobbies ?? [])].sort((a, b) => b.createdAt - a.createdAt), [openLobbies], ); + const getOverlayUrl = useCallback( + (matchId: string, seat: "host" | "away") => + buildStreamOverlayUrl({ + matchId, + seat, + }), + [], + ); useEffect(() => { if (myLobby?.status === "active") { @@ -140,8 +149,8 @@ export function Pvp() { })) as CreateResult; setMessage( visibility === "private" - ? `Private lobby ready. Join code: ${created.joinCode ?? "n/a"}` - : "Public lobby created.", + ? `Private arena ready. Join code: ${created.joinCode ?? "n/a"}` + : "Public arena created.", ); clearFlash(); } catch (err: any) { @@ -150,7 +159,7 @@ export function Pvp() { setBusyKey(null); } }, - [clearFlash, createPvpLobby, resetNotices], + [clearFlash, createPvpLobby, pongEnabled, redemptionEnabled, resetNotices], ); const handleJoinLobby = useCallback( @@ -196,7 +205,7 @@ export function Pvp() { setBusyKey("cancel"); try { await cancelPvpLobby({ matchId: myLobby.matchId }); - setMessage("Lobby canceled."); + setMessage("Arena canceled."); clearFlash(); } catch (err: any) { setError(err?.message ?? "Failed to cancel lobby."); @@ -232,7 +241,7 @@ export function Pvp() { initial={{ opacity: 0, y: -15 }} animate={{ opacity: 1, y: 0 }} > - PvP Lobby + Agent vs Agent PvP - Human vs Human duels + Human-hosted invites for Milady agents + Keep humans in spectator mode. Agents handle both seats. @@ -262,7 +271,7 @@ export function Pvp() {

- Create Lobby + Create Agent Lobby

+

+ Share the match ID with autonomous agents. Humans can watch through overlay links. +

{/* House rules toggles */}
@@ -309,7 +321,7 @@ export function Pvp() { {!canCreate && (

- You already have a waiting/active lobby below. + You already have a waiting/active arena below.

)}
@@ -326,7 +338,7 @@ export function Pvp() {

- Join Private Lobby + Join Agent Lobby by Code

- {busyKey === "join:code" ? "Joining..." : "Join by Code"} + {busyKey === "join:code" ? "Joining..." : "Join Arena"}
@@ -370,7 +382,7 @@ export function Pvp() { - Your Waiting Lobby + Your Waiting Agent Arena

Visibility: {myLobby.visibility} @@ -406,11 +418,27 @@ export function Pvp() { disabled={busyKey !== null} className="tcg-button-primary px-3 py-2 text-[10px] disabled:opacity-60" > - {busyKey === "cancel" ? "Canceling..." : "Cancel Lobby"} + {busyKey === "cancel" ? "Canceling..." : "Cancel Arena"} + + Watch Host Overlay + + + Watch Away Overlay +

- Agents can join this lobby via JOIN_LTCG_MATCH using the match ID. + Agents can join via JOIN_LTCG_MATCH using this match ID.

@@ -427,10 +455,10 @@ export function Pvp() {

- Open Public Lobbies + Open Agent Arenas

{sortedOpenLobbies.length === 0 ? ( -

No public lobbies are open right now.

+

No public agent arenas are open right now.

) : (
@@ -446,7 +474,7 @@ export function Pvp() { >

- Host: {lobby.hostUsername} + Controller: {lobby.hostUsername}

@@ -464,16 +492,26 @@ export function Pvp() { )}

- handleJoinLobby(lobby.matchId)} - disabled={busyKey !== null} - className="tcg-button px-3 py-2 text-[10px] shrink-0 disabled:opacity-60" - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} - > - {busyKey === `join:${lobby.matchId}` ? "Joining..." : "Join"} - +
+ + Watch + + handleJoinLobby(lobby.matchId)} + disabled={busyKey !== null} + className="tcg-button-primary px-3 py-2 text-[10px] shrink-0 disabled:opacity-60" + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + {busyKey === `join:${lobby.matchId}` ? "Joining..." : "Join"} + +
))}
@@ -493,7 +531,7 @@ export function Pvp() { )}
- +
); } diff --git a/apps/web-tanstack/src/app/pages/Story.tsx b/apps/web-tanstack/src/app/pages/Story.tsx index f88eb78..a2a0db7 100644 --- a/apps/web-tanstack/src/app/pages/Story.tsx +++ b/apps/web-tanstack/src/app/pages/Story.tsx @@ -1,5 +1,5 @@ import { StoryProvider, ChapterMap, StoryIntro, DialogueBox } from "@/components/story"; -import { TrayNav } from "@/components/layout/TrayNav"; +import { AgentOverlayNav } from "@/components/layout/AgentOverlayNav"; export function Story() { return ( @@ -7,7 +7,7 @@ export function Story() { - + ); } diff --git a/apps/web-tanstack/src/app/pages/StoryChapter.tsx b/apps/web-tanstack/src/app/pages/StoryChapter.tsx index 8b99f3f..41928e0 100644 --- a/apps/web-tanstack/src/app/pages/StoryChapter.tsx +++ b/apps/web-tanstack/src/app/pages/StoryChapter.tsx @@ -12,7 +12,7 @@ import { useStory, type Stage, } from "@/components/story"; -import { TrayNav } from "@/components/layout/TrayNav"; +import { AgentOverlayNav } from "@/components/layout/AgentOverlayNav"; import { SkeletonGrid } from "@/components/ui/Skeleton"; import { STAGES_BG, QUESTIONS_LABEL } from "@/lib/blobUrls"; import { normalizeMatchId } from "@/lib/matchIds"; @@ -46,7 +46,7 @@ export function StoryChapter() { - + ); } diff --git a/apps/web-tanstack/src/app/pages/StreamOverlay.test.ts b/apps/web-tanstack/src/app/pages/StreamOverlay.test.ts index c62b67f..3e9b610 100644 --- a/apps/web-tanstack/src/app/pages/StreamOverlay.test.ts +++ b/apps/web-tanstack/src/app/pages/StreamOverlay.test.ts @@ -15,6 +15,18 @@ vi.mock("@/hooks/useStreamOverlay", () => ({ useStreamOverlay: (params: StreamOverlayParams) => useStreamOverlayMock(params), })); +vi.mock("@/components/audio/AudioProvider", () => ({ + useAudio: () => ({ + pauseMusic: vi.fn(), + resumeMusic: vi.fn(), + setMusicMuted: vi.fn(), + setMusicVolume: vi.fn(), + setSfxMuted: vi.fn(), + setSfxVolume: vi.fn(), + stopMusic: vi.fn(), + }), +})); + const baseOverlayData = { loading: false, error: null, @@ -24,6 +36,7 @@ const baseOverlayData = { timeline: [], cardLookup: {}, chatMessages: [], + streamAudioControl: null, agentMonsters: [], opponentMonsters: [], agentSpellTraps: [], diff --git a/apps/web-tanstack/src/app/pages/StreamOverlay.tsx b/apps/web-tanstack/src/app/pages/StreamOverlay.tsx index a3c8675..44526f4 100644 --- a/apps/web-tanstack/src/app/pages/StreamOverlay.tsx +++ b/apps/web-tanstack/src/app/pages/StreamOverlay.tsx @@ -16,6 +16,7 @@ import { useSearchParams } from "@/router/react-router"; import { useEffect, useRef, useState } from "react"; +import { motion } from "framer-motion"; import { useStreamOverlay, type StreamChatMessage } from "@/hooks/useStreamOverlay"; import { parseStreamOverlayParams } from "@/lib/streamOverlayParams"; import { FieldRow } from "@/components/game/FieldRow"; @@ -28,6 +29,8 @@ import type { CardDefinition } from "@/lib/convexTypes"; import type { SpectatorSpellTrapCard } from "@/lib/spectatorAdapter"; import { ConvexHttpClient } from "convex/browser"; import { apiAny } from "@/lib/convexHelpers"; +import { LANDING_BG } from "@/lib/blobUrls"; +import { useAudio } from "@/components/audio/AudioProvider"; const MAX_LP = 4000; const TICKER_COUNT = 5; @@ -50,13 +53,24 @@ export function StreamOverlay() { timeline, cardLookup, chatMessages, + streamAudioControl, agentMonsters, opponentMonsters, agentSpellTraps, opponentSpellTraps, } = useStreamOverlay(overlayParams); + const { + pauseMusic, + resumeMusic, + setMusicMuted, + setMusicVolume, + setSfxMuted, + setSfxVolume, + stopMusic, + } = useAudio(); const [fallbackMatchState, setFallbackMatchState] = useState(null); + const audioControlSignatureRef = useRef(null); useEffect(() => { if (matchState) { @@ -118,6 +132,46 @@ export function StreamOverlay() { }; }, [apiUrl, apiKey, matchId, matchState, seat]); + useEffect(() => { + if (!streamAudioControl) return; + + const signature = JSON.stringify({ + playbackIntent: streamAudioControl.playbackIntent, + musicVolume: streamAudioControl.musicVolume, + sfxVolume: streamAudioControl.sfxVolume, + musicMuted: streamAudioControl.musicMuted, + sfxMuted: streamAudioControl.sfxMuted, + updatedAt: streamAudioControl.updatedAt, + }); + + if (audioControlSignatureRef.current === signature) return; + audioControlSignatureRef.current = signature; + + setMusicVolume(streamAudioControl.musicVolume); + setSfxVolume(streamAudioControl.sfxVolume); + setMusicMuted(streamAudioControl.musicMuted); + setSfxMuted(streamAudioControl.sfxMuted); + + if (streamAudioControl.playbackIntent === "paused") { + pauseMusic(); + return; + } + if (streamAudioControl.playbackIntent === "stopped") { + stopMusic(); + return; + } + resumeMusic(); + }, [ + pauseMusic, + resumeMusic, + setMusicMuted, + setMusicVolume, + setSfxMuted, + setSfxVolume, + stopMusic, + streamAudioControl, + ]); + const effectiveMatchState = matchState ?? fallbackMatchState; // Expose snapshot hook for browserObserver compatibility. @@ -217,11 +271,45 @@ export function StreamOverlay() { function OverlayShell({ children }: { children: React.ReactNode }) { return ( -
- {children} +
+
+
+ +
+ +
+
+ + + Agent Spectator Overlay + +
+ {children} +
); } diff --git a/apps/web-tanstack/src/app/pages/Watch.tsx b/apps/web-tanstack/src/app/pages/Watch.tsx index 3b11e6b..c90e575 100644 --- a/apps/web-tanstack/src/app/pages/Watch.tsx +++ b/apps/web-tanstack/src/app/pages/Watch.tsx @@ -1,413 +1,264 @@ -import { useState, useEffect, useRef } from "react"; -import * as Sentry from "@sentry/react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { motion } from "framer-motion"; -import { TrayNav } from "@/components/layout/TrayNav"; -import { - getLiveStreams, - getRetakeConfig, - streamUrl, - type LiveStreamer, -} from "@/lib/retake"; -import { - WATCH_BG, - MENU_TEXTURE, - MILUNCHLADY_PFP, - RETRO_TV, - STREAM_OVERLAY, - TAPE, - DECO_PILLS, -} from "@/lib/blobUrls"; -import { useScrollReveal } from "@/hooks/useScrollReveal"; -import { StickerBadge } from "@/components/ui/StickerBadge"; -import { SpeechBubble } from "@/components/ui/SpeechBubble"; -import { SpeedLines } from "@/components/ui/SpeedLines"; -import { DecorativeScatter } from "@/components/ui/DecorativeScatter"; +import { apiAny, useConvexQuery } from "@/lib/convexHelpers"; +import { AgentOverlayNav } from "@/components/layout/AgentOverlayNav"; +import { AmbientBackground } from "@/components/ui/AmbientBackground"; +import { LANDING_BG, MENU_TEXTURE } from "@/lib/blobUrls"; +import { buildStreamOverlayUrl, type StreamOverlaySeat } from "@/lib/streamOverlayParams"; -const LUNCHTABLE_AGENT = "milunchlady"; -const RETAKE_CONFIG = getRetakeConfig(); +type PvpLobbySummary = { + matchId: string; + hostUserId: string; + hostUsername: string; + visibility: "public" | "private"; + joinCode: string | null; + status: "waiting" | "active" | "ended" | "canceled"; + createdAt: number; + activatedAt: number | null; + endedAt: number | null; + pongEnabled: boolean; + redemptionEnabled: boolean; +}; -const emptyStateScatter = [ - { src: TAPE, size: 64, opacity: 0.2, rotation: 15 }, - { src: DECO_PILLS, size: 48, opacity: 0.15, rotation: -10 }, - { src: TAPE, size: 52, opacity: 0.18, rotation: -25 }, - { src: DECO_PILLS, size: 40, opacity: 0.12, rotation: 30 }, -]; +export function Watch() { + const [matchId, setMatchId] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [seat, setSeat] = useState("host"); + const [copiedMessage, setCopiedMessage] = useState(""); -/** Film-strip border CSS for the horizontal scroll container */ -const filmStripStyles = ` - .film-strip-container { - position: relative; - } - .film-strip-container::before, - .film-strip-container::after { - content: ""; - position: absolute; - left: 0; - right: 0; - height: 12px; - z-index: 2; - pointer-events: none; - background-image: repeating-linear-gradient( - 90deg, - #121212 0px, - #121212 10px, - transparent 10px, - transparent 16px - ); - opacity: 0.25; - } - .film-strip-container::before { - top: 0; - } - .film-strip-container::after { - bottom: 0; - } -`; + const openLobbies = useConvexQuery(apiAny.game.listPublicPvpLobbies, { includeActive: true }) as + | PvpLobbySummary[] + | undefined; -function StreamCardReveal({ children, index }: { children: React.ReactNode; index: number }) { - const { ref, inView, delay } = useScrollReveal({ index, threshold: 0.1 }); - return ( -
- {children} -
+ const sortedOpenLobbies = useMemo( + () => [...(openLobbies ?? [])].sort((a, b) => b.createdAt - a.createdAt), + [openLobbies], ); -} -export function Watch() { - const [streams, setStreams] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(false); - const mountedRef = useRef(true); + const normalizedMatchId = matchId.trim(); + const normalizedApiKey = apiKey.trim(); + const canOpenOverlay = normalizedMatchId.length > 0; - useEffect(() => { - mountedRef.current = true; + const overlayPath = useMemo( + () => + buildStreamOverlayUrl({ + matchId: normalizedMatchId || null, + apiKey: normalizedApiKey || null, + seat, + }), + [normalizedApiKey, normalizedMatchId, seat], + ); - async function fetchStreams() { - try { - const live = await getLiveStreams(RETAKE_CONFIG.apiUrl); - if (mountedRef.current) { - setStreams(live); - setError(false); - } - } catch (err) { - Sentry.captureException(err); - if (mountedRef.current) setError(true); - } finally { - if (mountedRef.current) setLoading(false); - } - } + const openOverlay = useCallback(() => { + if (!canOpenOverlay || typeof window === "undefined") return; + window.open(overlayPath, "_blank", "noopener,noreferrer"); + }, [canOpenOverlay, overlayPath]); - fetchStreams(); - const interval = setInterval(fetchStreams, 30_000); - return () => { - mountedRef.current = false; - clearInterval(interval); - }; - }, []); + const copyOverlayUrl = useCallback(async () => { + if (!canOpenOverlay) return; + const absoluteUrl = + typeof window === "undefined" ? overlayPath : `${window.location.origin}${overlayPath}`; - // Separate our agent from others - const list = Array.isArray(streams) ? streams : []; - const ourAgent = list.find( - (s) => s.username?.toLowerCase() === LUNCHTABLE_AGENT.toLowerCase(), - ); - const otherStreams = list.filter( - (s) => s.username?.toLowerCase() !== LUNCHTABLE_AGENT.toLowerCase(), - ); + try { + await navigator.clipboard.writeText(absoluteUrl); + setCopiedMessage("Overlay URL copied."); + } catch { + setCopiedMessage("Clipboard unavailable."); + } + }, [canOpenOverlay, overlayPath]); + + useEffect(() => { + if (!copiedMessage) return; + const timeout = setTimeout(() => setCopiedMessage(""), 2200); + return () => clearTimeout(timeout); + }, [copiedMessage]); return (
-
+ -
- {/* Header with SpeedLines behind */} -
- {/* Speed lines behind the title */} -
- -
- +
+
- Watch Live + Spectator Overlay - AI agents streaming LunchTable on retake.tv + Humans watch here. Agents play in Story and PvP arenas. -
+ - {/* Featured: LunchLady with RETRO_TV frame */} +
- {/* RETRO_TV decorative frame */} -
- + Open Overlay by Match ID +

+
+ setMatchId(event.target.value)} + placeholder="match_123..." + className="border-2 border-[#121212] bg-white px-3 py-2 text-sm font-mono" /> + +
-
-
- -
- {/* Agent avatar */} -
- {LUNCHTABLE_AGENT} -
- -
-
-

- {LUNCHTABLE_AGENT} -

- {ourAgent ? ( - - ) : ( - - Offline - - )} -
- - {/* Description in SpeechBubble */} -
- - The official LunchTable AI agent — plays, streams, and - trash-talks on retake.tv - -
+
+ setApiKey(event.target.value)} + placeholder="Optional API key" + className="border-2 border-[#121212] bg-white px-3 py-2 text-xs font-mono flex-1 min-w-[220px]" + /> + + {copiedMessage && ( + {copiedMessage} + )} +
-
- - - {ourAgent ? "Watch Now" : "Visit Channel"} - +

+ URL preview: {overlayPath} +

- {ourAgent?.viewer_count != null && ( - - {ourAgent.viewer_count} watching - - )} -
+ {canOpenOverlay ? ( +
+
+ Live Preview
+