Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 36 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 |
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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`)
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion apps/web-tanstack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
21 changes: 21 additions & 0 deletions apps/web-tanstack/src/app/components/audio/AudioProvider.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
33 changes: 33 additions & 0 deletions apps/web-tanstack/src/app/components/audio/AudioProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ const VALID_AUDIO_PLAYBACK_INTENTS = new Set<AudioPlaybackIntent>([
"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));
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 7 additions & 5 deletions apps/web-tanstack/src/app/components/auth/PrivyAuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,32 @@ 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 (
<PrivyProvider
appId={PRIVY_APP_ID}
config={{
loginMethods: ["email", "telegram", "discord"],
// Mirror Retake's wallet-first auth flow.
loginMethods: ["wallet"],
...(disableEmbeddedWallets
? {}
: {
embeddedWallets: {
solana: { createOnLogin: "users-without-wallets" },
solana: { createOnLogin: "off" },
},
}),
appearance: {
theme: "dark",
accentColor: "#ffcc00",
showWalletLoginFirst: true,
walletChainType: "solana-only",
walletList: ["phantom", "solflare", "backpack", "detected_solana_wallets"],
},
}}
>
{children}
</PrivyProvider>
);
}

Original file line number Diff line number Diff line change
@@ -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"');
});
});
64 changes: 64 additions & 0 deletions apps/web-tanstack/src/app/components/layout/AgentOverlayNav.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="fixed left-1/2 -translate-x-1/2 z-40 px-2"
style={{ bottom: "calc(0.9rem + var(--safe-area-bottom))" }}
>
<motion.nav
className="paper-panel scanner-noise px-2 py-2 flex items-center gap-1.5 shadow-zine-lg"
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 360, damping: 28 }}
aria-label="Agent overlay navigation"
>
{OVERLAY_NAV_ITEMS.map((item) => {
const isActive = item.id === active;
return (
<button
key={item.id}
type="button"
onClick={() => handleNavigate(item)}
className={`px-3 py-2 text-[10px] md:text-[11px] uppercase tracking-wider font-black border-2 transition-colors ${
isActive
? "bg-[#121212] text-[#ffcc00] border-[#121212]"
: "bg-white text-[#121212] border-[#121212] hover:bg-[#ffcc00]"
}`}
style={{ fontFamily: "Outfit, sans-serif" }}
aria-current={isActive ? "page" : undefined}
>
{item.label}
</button>
);
})}
</motion.nav>
</div>
);
}
10 changes: 8 additions & 2 deletions apps/web-tanstack/src/app/components/layout/Breadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ const ROUTE_LABELS: Record<string, string> = {
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",
};

Expand All @@ -37,15 +37,21 @@ const ROUTE_ACCENT: Record<string, string> = {
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[] {
Expand Down
Loading
Loading