From c67ca54e9e6464d6e808cef74513168594e12c6f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 19:45:02 +0000 Subject: [PATCH 1/6] build(deps-dev): bump vite-tsconfig-paths from 5.1.4 to 6.1.1 Bumps [vite-tsconfig-paths](https://github.com/aleclarson/vite-tsconfig-paths) from 5.1.4 to 6.1.1. - [Release notes](https://github.com/aleclarson/vite-tsconfig-paths/releases) - [Commits](https://github.com/aleclarson/vite-tsconfig-paths/compare/v5.1.4...v6.1.1) --- updated-dependencies: - dependency-name: vite-tsconfig-paths dependency-version: 6.1.1 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- apps/web-tanstack/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" } } From 010a6cf9be6d1eb242745a58999c124570526f1a Mon Sep 17 00:00:00 2001 From: dexploarer Date: Wed, 25 Feb 2026 15:08:57 -0500 Subject: [PATCH 2/6] fix(build): ensure engine resolves in clean deploy environments --- package.json | 3 ++- packages/engine/package.json | 3 +++ scripts/test-package-exports.mjs | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 59bc0c9..e9e32b9 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "setup:env": "bash scripts/setup-dev-env.sh", "setup:agent-auth": "bash scripts/setup-dev-agent-auth.sh", "setup:worktree:auto": "bash scripts/run-worktree-automation.sh", - "build": "cd apps/web-tanstack && bun run build && cd ../.. && bun run check:bundle-metrics", + "build": "bun run build:workspace:core && cd apps/web-tanstack && bun run build && cd ../.. && bun run check:bundle-metrics", + "build:workspace:core": "bun run --filter @lunchtable/engine build", "build:web:tanstack": "cd apps/web-tanstack && bun run build", "dev": "concurrently -n convex,web -c blue,magenta \"bun run dev:convex\" \"bun run dev:web\"", "dev:tanstack": "concurrently -n convex,web-ts -c blue,cyan \"bun run dev:convex\" \"bun run dev:web:tanstack\"", diff --git a/packages/engine/package.json b/packages/engine/package.json index f12d1cc..9d84cf2 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -5,14 +5,17 @@ "type": "module", "exports": { ".": { + "@convex-dev/component-source": "./src/index.ts", "types": "./dist/index.d.ts", "import": "./dist/index.js" }, "./types": { + "@convex-dev/component-source": "./src/types/index.ts", "types": "./dist/types/index.d.ts", "import": "./dist/types/index.js" }, "./rules": { + "@convex-dev/component-source": "./src/rules/index.ts", "types": "./dist/rules/index.d.ts", "import": "./dist/rules/index.js" } diff --git a/scripts/test-package-exports.mjs b/scripts/test-package-exports.mjs index a41c686..f9e7b87 100644 --- a/scripts/test-package-exports.mjs +++ b/scripts/test-package-exports.mjs @@ -29,14 +29,17 @@ const PACKAGES = [ dir: join(PACKAGES_DIR, "engine"), exports: { ".": { + "@convex-dev/component-source": "./src/index.ts", import: "./dist/index.js", types: "./dist/index.d.ts", }, "./types": { + "@convex-dev/component-source": "./src/types/index.ts", import: "./dist/types/index.js", types: "./dist/types/index.d.ts", }, "./rules": { + "@convex-dev/component-source": "./src/rules/index.ts", import: "./dist/rules/index.js", types: "./dist/rules/index.d.ts", }, From cb31c12ea504cde5a6102aa29bbe688ef19c46f1 Mon Sep 17 00:00:00 2001 From: dexploarer Date: Wed, 25 Feb 2026 18:45:42 -0500 Subject: [PATCH 3/6] feat: simplify agent overlays and retake-auth lobby pipeline --- .../app/components/auth/PrivyAuthProvider.tsx | 12 +- .../components/layout/AgentOverlayNav.test.ts | 37 ++ .../app/components/layout/AgentOverlayNav.tsx | 79 +++ .../src/app/components/layout/Breadcrumb.tsx | 9 +- .../hooks/auth/extractPrimaryWallet.test.ts | 65 ++ .../app/hooks/auth/extractPrimaryWallet.ts | 82 +++ .../src/app/hooks/auth/useUserSync.ts | 17 +- .../src/app/lib/streamOverlayParams.test.ts | 43 ++ .../src/app/lib/streamOverlayParams.ts | 32 + .../web-tanstack/src/app/pages/AgentLobby.tsx | 455 ++++++++++++++ apps/web-tanstack/src/app/pages/Home.tsx | 65 +- .../web-tanstack/src/app/pages/Onboarding.tsx | 231 ++++++- apps/web-tanstack/src/app/pages/Pvp.tsx | 98 ++- apps/web-tanstack/src/app/pages/Story.tsx | 4 +- .../src/app/pages/StoryChapter.tsx | 4 +- .../src/app/pages/StreamOverlay.tsx | 55 +- apps/web-tanstack/src/app/pages/Watch.tsx | 571 +++++++----------- apps/web-tanstack/src/routeTree.gen.ts | 21 + apps/web-tanstack/src/routes/agent-lobby.tsx | 15 + bun.lock | 4 +- .../__tests__/agentLobby.integration.test.ts | 56 ++ convex/__tests__/auth.integration.test.ts | 71 ++- .../publicLobbies.integration.test.ts | 26 + convex/agentLobby.ts | 246 ++++++++ convex/auth.ts | 201 +++++- convex/game.ts | 41 ++ convex/schema.ts | 30 + 27 files changed, 2113 insertions(+), 457 deletions(-) create mode 100644 apps/web-tanstack/src/app/components/layout/AgentOverlayNav.test.ts create mode 100644 apps/web-tanstack/src/app/components/layout/AgentOverlayNav.tsx create mode 100644 apps/web-tanstack/src/app/hooks/auth/extractPrimaryWallet.test.ts create mode 100644 apps/web-tanstack/src/app/hooks/auth/extractPrimaryWallet.ts create mode 100644 apps/web-tanstack/src/app/pages/AgentLobby.tsx create mode 100644 apps/web-tanstack/src/routes/agent-lobby.tsx create mode 100644 convex/__tests__/agentLobby.integration.test.ts create mode 100644 convex/__tests__/publicLobbies.integration.test.ts create mode 100644 convex/agentLobby.ts 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..5eeaff8 --- /dev/null +++ b/apps/web-tanstack/src/app/components/layout/AgentOverlayNav.test.ts @@ -0,0 +1,37 @@ +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: "home" })); + expect(html).toContain("Home"); + expect(html).toContain("Story"); + expect(html).toContain("Agent PvP"); + 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..8eb5caa --- /dev/null +++ b/apps/web-tanstack/src/app/components/layout/AgentOverlayNav.tsx @@ -0,0 +1,79 @@ +import { useCallback } from "react"; +import { motion } from "framer-motion"; +import { usePrivy } from "@privy-io/react-auth"; +import { useNavigate } from "@/router/react-router"; +import { storeRedirect } from "@/hooks/auth/usePostLoginRedirect"; +import { PRIVY_ENABLED } from "@/lib/auth/privyEnv"; + +type OverlayNavItem = { + id: "home" | "story" | "pvp" | "lobby" | "watch"; + label: string; + path: string; + requiresAuth: boolean; +}; + +const OVERLAY_NAV_ITEMS: OverlayNavItem[] = [ + { id: "home", label: "Home", path: "/", requiresAuth: false }, + { id: "story", label: "Story", path: "/story", requiresAuth: true }, + { id: "pvp", label: "Agent PvP", path: "/pvp", requiresAuth: true }, + { id: "lobby", label: "Lobby", path: "/agent-lobby", requiresAuth: true }, + { id: "watch", label: "Watch", path: "/watch", requiresAuth: false }, +]; + +export function AgentOverlayNav({ + active, +}: { + active: OverlayNavItem["id"]; +}) { + const navigate = useNavigate(); + const { authenticated, login } = PRIVY_ENABLED + ? usePrivy() + : { authenticated: false, login: () => {} }; + + const handleNavigate = useCallback( + (item: OverlayNavItem) => { + if (item.requiresAuth && !authenticated) { + storeRedirect(item.path); + login(); + return; + } + navigate(item.path); + }, + [authenticated, login, 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..d20104a 100644 --- a/apps/web-tanstack/src/app/components/layout/Breadcrumb.tsx +++ b/apps/web-tanstack/src/app/components/layout/Breadcrumb.tsx @@ -18,6 +18,7 @@ const ROUTE_LABELS: Record = { settings: "Settings", leaderboard: "Leaderboard", watch: "Watch", + "agent-lobby": "Agent Lobby", onboarding: "Onboarding", about: "About", privacy: "Privacy", @@ -37,15 +38,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/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/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/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/AgentLobby.tsx b/apps/web-tanstack/src/app/pages/AgentLobby.tsx new file mode 100644 index 0000000..f8f9470 --- /dev/null +++ b/apps/web-tanstack/src/app/pages/AgentLobby.tsx @@ -0,0 +1,455 @@ +import { useCallback, useMemo, useState } from "react"; +import { motion } from "framer-motion"; +import { useNavigate } from "@/router/react-router"; +import { apiAny, useConvexMutation, 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 } from "@/lib/streamOverlayParams"; + +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[]; +}; + +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", + }); +} + +export function AgentLobby() { + const navigate = useNavigate(); + const [draft, setDraft] = useState(""); + const [busyKey, setBusyKey] = useState(null); + const [error, setError] = useState(""); + + const snapshot = useConvexQuery(apiAny.agentLobby.getLobbySnapshot, { limit: 80 }) as + | LobbySnapshot + | undefined; + const postLobbyMessage = useConvexMutation(apiAny.agentLobby.postLobbyMessage); + const setRetakePipelineEnabled = useConvexMutation(apiAny.auth.setRetakePipelineEnabled); + const joinPvpLobby = useConvexMutation(apiAny.game.joinPvpLobby); + + const openLobbies = useMemo(() => { + if (!snapshot?.openLobbies) return []; + return [...snapshot.openLobbies].sort((a, b) => b.createdAt - a.createdAt); + }, [snapshot?.openLobbies]); + + const handleSendMessage = useCallback(async () => { + const text = draft.trim(); + if (!text) return; + setError(""); + setBusyKey("send"); + try { + await postLobbyMessage({ text, source: "agent" }); + setDraft(""); + } catch (err: any) { + setError(err?.message ?? "Failed to send chat message."); + } finally { + setBusyKey(null); + } + }, [draft, postLobbyMessage]); + + const handleTogglePipeline = useCallback(async () => { + if (!snapshot) return; + setError(""); + setBusyKey("pipeline"); + try { + await setRetakePipelineEnabled({ enabled: !snapshot.currentUser.pipelineEnabled }); + } catch (err: any) { + setError(err?.message ?? "Failed to toggle Retake pipeline."); + } finally { + setBusyKey(null); + } + }, [setRetakePipelineEnabled, snapshot]); + + const handleJoinLobby = useCallback( + async (matchId: string) => { + setError(""); + setBusyKey(`join:${matchId}`); + try { + await joinPvpLobby({ matchId }); + navigate(`/play/${matchId}`); + } catch (err: any) { + setError(err?.message ?? "Failed to join lobby."); + } finally { + setBusyKey(null); + } + }, + [joinPvpLobby, navigate], + ); + + return ( +
+
+ + +
+
+ + Agent Chat Lobby + + + Discover open arenas, coordinate with agents, and route streams through Retake overlays. + +
+ + {!snapshot ? ( +
+
+
+ ) : ( +
+
+
+
+
+

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

+

+ 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. Complete onboarding to connect it. +

+ )} +
+ +
+ {snapshot.currentUser.streamUrl && ( + + Open Retake Channel + + )} + +
+
+
+ + {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 Matches +

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

+ No active story matches detected. +

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

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

+

{storyMatch.matchId}

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

+ Lobby Chat +

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

+ Chat is quiet. Drop the first message. +

+ ) : ( + snapshot.messages.map((message) => ( +
+
+ + {message.senderName} + +
+ + {message.source} + + + {formatTimestamp(message.createdAt)} + +
+
+

{message.text}

+
+ )) + )} +
+ +
+ setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + void handleSendMessage(); + } + }} + maxLength={280} + className="flex-1 border-2 border-[#121212] bg-white px-3 py-2 text-sm" + placeholder="Message the agent lobby" + /> + +
+
+
+
+
+ )} +
+ + +
+ ); +} diff --git a/apps/web-tanstack/src/app/pages/Home.tsx b/apps/web-tanstack/src/app/pages/Home.tsx index f8576a2..daa2b0d 100644 --- a/apps/web-tanstack/src/app/pages/Home.tsx +++ b/apps/web-tanstack/src/app/pages/Home.tsx @@ -4,7 +4,7 @@ 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 { AgentOverlayNav } from "@/components/layout/AgentOverlayNav"; import { PRIVY_ENABLED } from "@/lib/auth/privyEnv"; import { AmbientBackground } from "@/components/ui/AmbientBackground"; import { useCardTilt } from "@/hooks/useCardTilt"; @@ -14,7 +14,7 @@ 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, + STORY_BG, WATCH_BG, PVP_BG, TAPE, CIGGARETTE_TRAY, } from "@/lib/blobUrls"; @@ -210,7 +210,7 @@ export function Home() { className="text-base md:text-lg text-[#121212] drop-shadow-none" style={{ fontFamily: "Special Elite, cursive" }} > - School of Hard Knocks + Agent Arena Overlay @@ -218,14 +218,14 @@ export function Home() { {/* Comic panels grid */} goTo("/story", true)} impactWord="FIGHT!" @@ -234,57 +234,44 @@ export function Home() { goTo("/collection", true)} - impactWord="COLLECT!" + title="Agent PvP" + subtitle="Create and join agent-vs-agent duels" + bgImage={PVP_BG} + onClick={() => goTo("/pvp", true)} + impactWord="DUEL!" > -
+
goTo("/decks", true)} - impactWord="BUILD!" + title="Agent Lobby" + subtitle="Chat with agents and discover open arenas" + bgContain + onClick={() => goTo("/agent-lobby", true)} + impactWord="SYNC!" > -
+
💬
goTo("/watch", false)} impactWord="WATCH!" >
- - goTo("/pvp", true)} - impactWord="DUEL!" - > -
-
-
- {isEmbedded && ( -

- Running inside milaidy -

- )} +

+ Agents play. Humans spectate. Everything runs in overlay mode. +

- + {!isEmbedded && }
); } 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..c5f74cd 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..d545baf 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..18ad90b 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.tsx b/apps/web-tanstack/src/app/pages/StreamOverlay.tsx index a3c8675..db0a501 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,7 @@ 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, STREAM_OVERLAY } from "@/lib/blobUrls"; const MAX_LP = 4000; const TICKER_COUNT = 5; @@ -217,11 +219,54 @@ 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..ef67570 100644 --- a/apps/web-tanstack/src/app/pages/Watch.tsx +++ b/apps/web-tanstack/src/app/pages/Watch.tsx @@ -1,413 +1,274 @@ -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, STREAM_OVERLAY } 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
+