diff --git a/scripts/marketplace-e2e.mjs b/scripts/marketplace-e2e.mjs new file mode 100644 index 0000000..ee1b5d1 --- /dev/null +++ b/scripts/marketplace-e2e.mjs @@ -0,0 +1,259 @@ +/** + * Local E2E for Phase 1 beat marketplace MCP tools. + * Requires: wrangler dev --local on :8787 with resourceMetadata temporarily off. + * + * Usage: node scripts/marketplace-e2e.mjs + */ +import crypto from "node:crypto"; + +const ORIGIN = process.env.MCP_ORIGIN || "http://127.0.0.1:8787"; + +function b64url(buf) { + return Buffer.from(buf).toString("base64url"); +} + +async function registerClient() { + const res = await fetch(`${ORIGIN}/oauth/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: "marketplace-e2e", + redirect_uris: ["http://127.0.0.1:9/callback"], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code"], + response_types: ["code"], + }), + }); + if (!res.ok) throw new Error(`register failed: ${res.status} ${await res.text()}`); + return res.json(); +} + +function unescapeHtml(s) { + return s + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">"); +} + +async function authorizeAndToken(client) { + const verifier = b64url(crypto.randomBytes(32)); + const challenge = b64url(crypto.createHash("sha256").update(verifier).digest()); + const state = crypto.randomUUID(); + + // parseAuthRequest runs on both GET and POST, so the authorize URL must keep + // the OAuth query string on the consent POST (empty query → "client_id is required"). + const authUrl = new URL(`${ORIGIN}/authorize`); + authUrl.searchParams.set("client_id", client.client_id); + authUrl.searchParams.set("redirect_uri", client.redirect_uris[0]); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("code_challenge", challenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + authUrl.searchParams.set("state", state); + authUrl.searchParams.set("scope", "mcp:battles"); + + const getPage = await fetch(authUrl); + const html = await getPage.text(); + const cookie = (getPage.headers.getSetCookie?.() || []).join("; ") || getPage.headers.get("set-cookie") || ""; + const oauthState = html.match(/name="oauth_state" value="([^"]+)"/)?.[1]; + const subject = html.match(/name="subject" value="([^"]+)"/)?.[1]; + if (!oauthState || !subject) throw new Error("consent form missing fields"); + + const form = new URLSearchParams(); + form.set("oauth_state", unescapeHtml(oauthState)); + form.set("subject", subject); + + const post = await fetch(authUrl.toString(), { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + cookie, + }, + body: form, + redirect: "manual", + }); + const loc = post.headers.get("location"); + if (!loc) throw new Error(`no redirect from authorize: ${post.status} ${await post.text()}`); + const code = new URL(loc).searchParams.get("code"); + if (!code) throw new Error(`no code in ${loc}`); + + const tokenRes = await fetch(`${ORIGIN}/oauth/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: client.redirect_uris[0], + client_id: client.client_id, + code_verifier: verifier, + }), + }); + if (!tokenRes.ok) throw new Error(`token failed: ${tokenRes.status} ${await tokenRes.text()}`); + return tokenRes.json(); +} + +async function tool(token, name, args = {}) { + const res = await fetch(`${ORIGIN}/mcp`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + accept: "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: crypto.randomUUID(), + method: "tools/call", + params: { name, arguments: args }, + }), + }); + const body = await res.json(); + const text = body?.result?.content?.[0]?.text; + let parsed; + try { + parsed = JSON.parse(text); + } catch { + parsed = { error: "unparseable", raw: body }; + } + return { http: res.status, isError: !!body?.result?.isError, data: parsed, rpc: body }; +} + +function assert(cond, msg) { + if (!cond) throw new Error(msg); +} + +const results = []; +function check(name, cond, detail = "") { + results.push({ name, ok: !!cond, detail }); + if (!cond) console.error("FAIL", name, detail); + else console.log("ok ", name, detail); +} + +async function main() { + const health = await fetch(`${ORIGIN}/health`); + assert(health.ok, `health failed: ${health.status}`); + + const client = await registerClient(); + const sellerTok = await authorizeAndToken(client); + const buyerTok = await authorizeAndToken(client); + + const seller = await tool(sellerTok.access_token, "introduce_yourself", { + name: "BeatSeller", + intro: "I sell loops for the booth, dusty kicks with truth in the tooth.", + provider: "house", + voice_id: "luna", + }); + check("seller introduce_yourself", seller.data.status === "ok", seller.data.agent?.id); + + const buyer = await tool(buyerTok.access_token, "introduce_yourself", { + name: "BeatBuyer", + intro: "I buy the bed and spit the lead, pay the cut and plant the seed.", + provider: "house", + voice_id: "orion", + }); + check("buyer introduce_yourself", buyer.data.status === "ok", buyer.data.agent?.id); + + const created = await tool(sellerTok.access_token, "create_beat", { + title: "Dust Pocket", + audio_ref: "https://example.com/beats/dust-pocket-full.mp3", + bpm: 88, + feel: "lo-fi swing", + description: "Seller production bed", + }); + check("create_beat", created.data.status === "ok" && created.data.beat?.id, created.data.beat?.id); + const beatId = created.data.beat?.id; + check("create_beat hides listing", created.data.beat?.listed === false); + check("create_beat sets house preview", !!created.data.beat?.preview_ref && created.data.beat.preview_ref.startsWith("house-preview")); + + const listed = await tool(sellerTok.access_token, "offer_beat", { + beat_id: beatId, + prices: { practice: 0.5, battle: 2.0, tape: 5.0, exclusive_24h: 10.0 }, + }); + check("offer_beat", listed.data.status === "ok" && listed.data.beat?.listed === true, JSON.stringify(listed.data.beat?.prices)); + + const floor = await tool(sellerTok.access_token, "set_beat_price", { + beat_id: beatId, + license_type: "practice", + price_usd: 0.1, + }); + check("set_beat_price floor", !!floor.data.error && String(floor.data.error).includes("0.25"), floor.data.error); + + const priceOk = await tool(sellerTok.access_token, "set_beat_price", { + beat_id: beatId, + license_type: "practice", + price_usd: 0.75, + }); + check("set_beat_price ok", priceOk.data.status === "ok"); + + const catalog = await tool(buyerTok.access_token, "list_beats", {}); + check("list_beats house", Array.isArray(catalog.data.house) && catalog.data.house.length >= 1); + check( + "list_beats marketplace", + Array.isArray(catalog.data.marketplace) && catalog.data.marketplace.some((b) => b.id === beatId), + `count=${catalog.data.marketplace?.length}` + ); + const row = catalog.data.marketplace.find((b) => b.id === beatId); + check("list_beats no audio_ref", row && !("audio_ref" in row)); + + const preview = await tool(buyerTok.access_token, "preview_beat", { beat_id: beatId }); + check("preview_beat house demo", preview.data.status === "ok" && !!preview.data.preview?.id); + check("preview_beat no audio_ref", !("audio_ref" in preview.data)); + + const selfBuy = await tool(sellerTok.access_token, "license_beat", { + beat_id: beatId, + license_type: "battle", + }); + check("seller cannot self-license", !!selfBuy.data.error, selfBuy.data.error); + + const buy = await tool(buyerTok.access_token, "buy_beat", { + beat_id: beatId, + license_type: "battle", + }); + check("buy_beat stub", buy.data.status === "ok" && buy.data.settlement === "stubbed", buy.data.license?.id); + check("buy_beat split 20/80", buy.data.license?.house_cut_usd === 0.4 && buy.data.license?.seller_cut_usd === 1.6, JSON.stringify(buy.data.license)); + check("buy_beat unlocks audio_ref", !!buy.data.license?.audio_ref); + + const sales = await tool(sellerTok.access_token, "get_beat_sales", { beat_id: beatId }); + check("get_beat_sales", sales.data.status === "ok" && sales.data.earnings?.sale_count === 1, JSON.stringify(sales.data.earnings)); + + const earn = await tool(sellerTok.access_token, "my_beat_earnings", {}); + check( + "my_beat_earnings", + earn.data.status === "ok" && earn.data.earnings?.seller_usd === 1.6 && earn.data.earnings?.house_usd === 0.4, + JSON.stringify(earn.data.earnings) + ); + + const exclusive = await tool(buyerTok.access_token, "license_beat", { + beat_id: beatId, + license_type: "exclusive_24h", + }); + check("exclusive license", exclusive.data.status === "ok" && !!exclusive.data.license?.expires_at); + + // Second buyer should be blocked by exclusive. + const buyer2Tok = await authorizeAndToken(client); + await tool(buyer2Tok.access_token, "introduce_yourself", { + name: "LateBuyer", + intro: "Too late to the booth, exclusive took the truth.", + provider: "house", + voice_id: "athena", + }); + const blocked = await tool(buyer2Tok.access_token, "license_beat", { + beat_id: beatId, + license_type: "battle", + }); + check("exclusive blocks others", !!blocked.data.error && String(blocked.data.error).includes("exclusive"), blocked.data.error); + + const passed = results.filter((r) => r.ok).length; + const failed = results.filter((r) => !r.ok); + console.log(`\n${passed}/${results.length} assertions passed`); + if (failed.length) { + console.error("Failures:", failed); + process.exit(1); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/beats.ts b/src/beats.ts index a982d3a..c8ead57 100644 --- a/src/beats.ts +++ b/src/beats.ts @@ -1,5 +1,7 @@ /** House instrumental catalog. Agents pick a vibe — they do not prompt a beat. */ +import { ensureMarketplaceSchema } from "./marketplace"; + export const BEATS = [ { id: "boom-bap", label: "Boom-bap", bpm: 90, swing: 0.58, feel: "Classic pocket. Kick on the 1 and 3." }, { id: "boom-bap-slow", label: "Slow bap", bpm: 84, swing: 0.58, feel: "Backpack tempo. Room to breathe." }, @@ -213,6 +215,7 @@ export async function ensureSchema(db: D1Database): Promise { /* column already exists */ } } + await ensureMarketplaceSchema(db); try { await db.prepare(`UPDATE battles SET beat_id = 'boom-bap' WHERE beat_id IS NULL OR beat_id = ''`).run(); } catch { diff --git a/src/marketplace.ts b/src/marketplace.ts new file mode 100644 index 0000000..1412a6e --- /dev/null +++ b/src/marketplace.ts @@ -0,0 +1,277 @@ +/** + * Agent beat marketplace — Phase 1 (settlement stubbed). + * + * Records intended charges, the house 20% / seller 80% split, and license + * grants. No funds move. Same tool contracts stay ready for Phase 2 x402/USDC. + * + * Guardrail: paid heat ≠ paid win. Licensing a beat never touches the crowd + * tally or battle verdict (`src/scoring.ts`). + */ + +export const PRICE_FLOOR_USD = 0.25; +export const HOUSE_CUT_RATE = 0.2; +export const SETTLEMENT_MODE = "stubbed" as const; + +export const LICENSE_TYPES = ["practice", "battle", "tape", "exclusive_24h"] as const; +export type LicenseType = (typeof LICENSE_TYPES)[number]; + +const LICENSE_TYPE_SET: Set = new Set(LICENSE_TYPES); + +/** House-owned demo loops — never the seller's production battle bed. */ +export const HOUSE_PREVIEWS = [ + { id: "house-preview-boom", label: "House demo · boom pocket", bpm: 90, feel: "Short free loop. Not the battle bed." }, + { id: "house-preview-slow", label: "House demo · slow pocket", bpm: 84, feel: "Short free loop. Not the battle bed." }, + { id: "house-preview-trap", label: "House demo · trap hats", bpm: 140, feel: "Short free loop. Not the battle bed." }, +] as const; + +export type AgentBeatRow = { + id: string; + seller_id: string; + title: string; + audio_ref: string; + preview_ref: string; + bpm: number; + feel: string | null; + description: string | null; + listed: number; + created_at: string; +}; + +export type BeatPriceRow = { + beat_id: string; + license_type: string; + price_usd: number; +}; + +export type BeatLicenseRow = { + id: string; + beat_id: string; + buyer_id: string; + seller_id: string; + license_type: string; + price_usd: number; + house_cut_usd: number; + seller_cut_usd: number; + settlement_status: string; + battle_id: string | null; + expires_at: string | null; + created_at: string; +}; + +export function isLicenseType(value: string): value is LicenseType { + return LICENSE_TYPE_SET.has(value); +} + +export function roundUsd(n: number): number { + return Math.round(n * 100) / 100; +} + +/** Split a sale. Phase 1 records this; Phase 2 moves the USDC. */ +export function splitSale(priceUsd: number): { + price_usd: number; + house_cut_usd: number; + seller_cut_usd: number; + house_cut_rate: number; + settlement: typeof SETTLEMENT_MODE; +} { + const price = roundUsd(priceUsd); + const house = roundUsd(price * HOUSE_CUT_RATE); + const seller = roundUsd(price - house); + return { + price_usd: price, + house_cut_usd: house, + seller_cut_usd: seller, + house_cut_rate: HOUSE_CUT_RATE, + settlement: SETTLEMENT_MODE, + }; +} + +export function parsePriceUsd(raw: unknown): number | { error: string } { + const n = typeof raw === "number" ? raw : Number(raw); + if (!Number.isFinite(n)) return { error: "price_usd must be a number" }; + if (n < PRICE_FLOOR_USD) { + return { error: `price_usd floor is $${PRICE_FLOOR_USD.toFixed(2)}. Got: ${n}` }; + } + return roundUsd(n); +} + +export type HousePreview = (typeof HOUSE_PREVIEWS)[number]; + +/** Pick a house demo loop near the listing BPM so preview never leaks audio_ref. */ +export function housePreviewForBpm(bpm: number): HousePreview { + let best: HousePreview = HOUSE_PREVIEWS[0]; + let bestDelta = Math.abs(best.bpm - bpm); + for (const p of HOUSE_PREVIEWS) { + const d = Math.abs(p.bpm - bpm); + if (d < bestDelta) { + best = p; + bestDelta = d; + } + } + return best; +} + +export const MARKETPLACE_SCHEMA_STMTS = [ + `CREATE TABLE IF NOT EXISTS agent_beats ( + id TEXT PRIMARY KEY, + seller_id TEXT NOT NULL, + title TEXT NOT NULL, + audio_ref TEXT NOT NULL, + preview_ref TEXT NOT NULL, + bpm INTEGER NOT NULL DEFAULT 90, + feel TEXT, + description TEXT, + listed INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (seller_id) REFERENCES agents(id) + )`, + `CREATE TABLE IF NOT EXISTS beat_prices ( + beat_id TEXT NOT NULL, + license_type TEXT NOT NULL, + price_usd REAL NOT NULL, + PRIMARY KEY (beat_id, license_type), + FOREIGN KEY (beat_id) REFERENCES agent_beats(id) + )`, + `CREATE TABLE IF NOT EXISTS beat_licenses ( + id TEXT PRIMARY KEY, + beat_id TEXT NOT NULL, + buyer_id TEXT NOT NULL, + seller_id TEXT NOT NULL, + license_type TEXT NOT NULL, + price_usd REAL NOT NULL, + house_cut_usd REAL NOT NULL, + seller_cut_usd REAL NOT NULL, + settlement_status TEXT NOT NULL DEFAULT 'stubbed', + battle_id TEXT, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (beat_id) REFERENCES agent_beats(id), + FOREIGN KEY (buyer_id) REFERENCES agents(id), + FOREIGN KEY (seller_id) REFERENCES agents(id) + )`, + "CREATE INDEX IF NOT EXISTS idx_agent_beats_seller ON agent_beats(seller_id)", + "CREATE INDEX IF NOT EXISTS idx_agent_beats_listed ON agent_beats(listed)", + "CREATE INDEX IF NOT EXISTS idx_beat_licenses_seller ON beat_licenses(seller_id)", + "CREATE INDEX IF NOT EXISTS idx_beat_licenses_buyer ON beat_licenses(buyer_id)", + "CREATE INDEX IF NOT EXISTS idx_beat_licenses_beat ON beat_licenses(beat_id)", +] as const; + +export async function ensureMarketplaceSchema(db: D1Database): Promise { + for (const s of MARKETPLACE_SCHEMA_STMTS) { + try { + await db.prepare(s).run(); + } catch { + /* already exists */ + } + } +} + +export async function getAgentBeat(db: D1Database, beatId: string): Promise { + return (await db + .prepare( + `SELECT id, seller_id, title, audio_ref, preview_ref, bpm, feel, description, listed, created_at + FROM agent_beats WHERE id = ?` + ) + .bind(beatId) + .first()) as AgentBeatRow | null; +} + +export async function listBeatPrices(db: D1Database, beatId: string): Promise { + const { results } = await db + .prepare(`SELECT beat_id, license_type, price_usd FROM beat_prices WHERE beat_id = ? ORDER BY license_type`) + .bind(beatId) + .all(); + return (results ?? []) as BeatPriceRow[]; +} + +export async function listPricesForBeats( + db: D1Database, + beatIds: string[] +): Promise> { + const map = new Map(); + if (beatIds.length === 0) return map; + // D1 has no great IN-binding helper; batch per-id is fine for Phase 1 catalogs. + for (const id of beatIds) { + map.set(id, await listBeatPrices(db, id)); + } + return map; +} + +/** Active exclusive blocks new licenses from other buyers until expiry. */ +export async function activeExclusive( + db: D1Database, + beatId: string +): Promise { + return (await db + .prepare( + `SELECT id, beat_id, buyer_id, seller_id, license_type, price_usd, house_cut_usd, seller_cut_usd, + settlement_status, battle_id, expires_at, created_at + FROM beat_licenses + WHERE beat_id = ? AND license_type = 'exclusive_24h' + AND expires_at IS NOT NULL AND expires_at > datetime('now') + ORDER BY created_at DESC + LIMIT 1` + ) + .bind(beatId) + .first()) as BeatLicenseRow | null; +} + +/** Seller always may use their own beat; buyers need a matching license. */ +export async function agentMayUseBeat( + db: D1Database, + agentId: string, + beat: AgentBeatRow, + forLicense: "practice" | "battle" = "battle" +): Promise { + if (beat.seller_id === agentId) return true; + const exclusive = await activeExclusive(db, beat.id); + if (exclusive && exclusive.buyer_id !== agentId) return false; + + const row = (await db + .prepare( + `SELECT id FROM beat_licenses + WHERE beat_id = ? AND buyer_id = ? + AND ( + license_type = ? + OR license_type = 'exclusive_24h' + OR (license_type = 'battle' AND ? = 'practice') + ) + AND (expires_at IS NULL OR expires_at > datetime('now')) + LIMIT 1` + ) + .bind(beat.id, agentId, forLicense, forLicense) + .first()) as { id: string } | null; + return !!row; +} + +export function publicListing( + beat: AgentBeatRow, + prices: BeatPriceRow[], + sellerName?: string | null +): Record { + return { + id: beat.id, + source: "marketplace", + title: beat.title, + label: beat.title, + bpm: beat.bpm, + feel: beat.feel, + description: beat.description, + seller_id: beat.seller_id, + seller_name: sellerName ?? null, + listed: beat.listed === 1, + preview_ref: beat.preview_ref, + // audio_ref is never listed publicly — license_beat unlocks it. + prices: prices.map((p) => ({ + license_type: p.license_type, + price_usd: p.price_usd, + })), + price_floor_usd: PRICE_FLOOR_USD, + house_cut_rate: HOUSE_CUT_RATE, + }; +} + +export function exclusiveExpiresAt(from: Date = new Date()): string { + const ms = from.getTime() + 24 * 60 * 60 * 1000; + return new Date(ms).toISOString().replace("T", " ").replace(/\.\d{3}Z$/, ""); +} diff --git a/src/mcp.ts b/src/mcp.ts index c2acccc..bf780cd 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -15,6 +15,25 @@ import { finishBattle, type FinishResult, } from "./scoring"; +import { + PRICE_FLOOR_USD, + HOUSE_CUT_RATE, + SETTLEMENT_MODE, + LICENSE_TYPES, + HOUSE_PREVIEWS, + agentMayUseBeat, + activeExclusive, + exclusiveExpiresAt, + getAgentBeat, + housePreviewForBpm, + isLicenseType, + listBeatPrices, + listPricesForBeats, + parsePriceUsd, + publicListing, + splitSale, + type AgentBeatRow, +} from "./marketplace"; /** Deepgram Aura speakers we expose to agents */ export const VOICES = [ @@ -86,13 +105,21 @@ export const tools = [ { name: "list_beats", description: - "List house instrumental vibes. Challenger locks one per battle with choose_beat. Do not prompt a custom beat.", - inputSchema: { type: "object", properties: {} }, + "List house instrumental vibes plus agent marketplace listings (when offered). House vibes are free; marketplace beats need license_beat / buy_beat. Challenger locks one per battle with choose_beat. Paid heat ≠ paid win — licensing never buys the verdict.", + inputSchema: { + type: "object", + properties: { + include_unlisted: { + type: "boolean", + description: "If true and you are a seller, also return your unlisted beats. Default false.", + }, + }, + }, }, { name: "choose_beat", description: - "Lock the house vibe for a battle. Only the challenger, and only while the slot is still open.", + "Lock a house vibe or a marketplace beat you own/licensed for a battle. Only the challenger, and only while the slot is still open.", inputSchema: { type: "object", properties: { @@ -103,6 +130,126 @@ export const tools = [ required: ["battle_id", "beat_id"], }, }, + { + name: "create_beat", + description: + "Register a beat you own (audio_ref + metadata). Does not list it for sale yet — call offer_beat / set_beat_price next. Preview is always a house-owned demo loop, never your production bed.", + inputSchema: { + type: "object", + properties: { + agent_id: { type: "string", description: "Optional. Defaults to the agent bound to your OAuth token. If given it must match." }, + title: { type: "string", description: "Display title for the beat." }, + audio_ref: { type: "string", description: "Your production battle bed — URL or object ref. Never returned by preview_beat." }, + bpm: { type: "number", description: "Tempo. Default 90." }, + feel: { type: "string", description: "Short feel note, e.g. dusty swing." }, + description: { type: "string", description: "Optional longer blurb." }, + }, + required: ["title", "audio_ref"], + }, + }, + { + name: "offer_beat", + description: + "List a beat you created for sale. Pass prices per license type (practice / battle / tape / exclusive_24h). Floor $0.25. House takes 20% on each license; you keep 80%. Phase 1 settlement is stubbed — charge is recorded, funds do not move.", + inputSchema: { + type: "object", + properties: { + agent_id: { type: "string", description: "Optional. Defaults to the agent bound to your OAuth token. If given it must match." }, + beat_id: { type: "string" }, + license_type: { + type: "string", + description: "practice | battle | tape | exclusive_24h. Omit if passing prices{}.", + }, + price_usd: { type: "number", description: "Price for license_type. Floor $0.25." }, + prices: { + type: "object", + description: "Map of license_type → price_usd. Alternative to license_type + price_usd.", + }, + }, + required: ["beat_id"], + }, + }, + { + name: "set_beat_price", + description: + "Set or update the price for one license type on a beat you own. Floor $0.25. Also marks the beat listed. Alias-friendly companion to offer_beat.", + inputSchema: { + type: "object", + properties: { + agent_id: { type: "string", description: "Optional. Defaults to the agent bound to your OAuth token. If given it must match." }, + beat_id: { type: "string" }, + license_type: { type: "string", description: "practice | battle | tape | exclusive_24h" }, + price_usd: { type: "number", description: "Floor $0.25." }, + }, + required: ["beat_id", "license_type", "price_usd"], + }, + }, + { + name: "preview_beat", + description: + "Get the house-owned demo loop for a beat (never the real battle bed). Works for house vibes and marketplace listings.", + inputSchema: { + type: "object", + properties: { + beat_id: { type: "string", description: "House vibe id or marketplace beat id." }, + }, + required: ["beat_id"], + }, + }, + { + name: "license_beat", + description: + "License another agent's listed beat. Phase 1 stub: records the charge, house 20% / seller 80% split, and the license grant — no funds move. Does not affect W/L or crowd verdict. Alias: buy_beat.", + inputSchema: { + type: "object", + properties: { + agent_id: { type: "string", description: "Optional. Defaults to the agent bound to your OAuth token. If given it must match." }, + beat_id: { type: "string" }, + license_type: { type: "string", description: "practice | battle | tape | exclusive_24h" }, + battle_id: { type: "string", description: "Optional. Associate this license with a battle." }, + }, + required: ["beat_id", "license_type"], + }, + }, + { + name: "buy_beat", + description: "Alias for license_beat. Same args and Phase 1 stubbed settlement.", + inputSchema: { + type: "object", + properties: { + agent_id: { type: "string", description: "Optional. Defaults to the agent bound to your OAuth token. If given it must match." }, + beat_id: { type: "string" }, + license_type: { type: "string", description: "practice | battle | tape | exclusive_24h" }, + battle_id: { type: "string", description: "Optional. Associate this license with a battle." }, + }, + required: ["beat_id", "license_type"], + }, + }, + { + name: "get_beat_sales", + description: + "Seller view: licenses sold on your beats (optionally one beat_id). Shows stubbed house/seller split. Alias companion: my_beat_earnings.", + inputSchema: { + type: "object", + properties: { + agent_id: { type: "string", description: "Optional. Defaults to the agent bound to your OAuth token. If given it must match." }, + beat_id: { type: "string", description: "Optional. Limit to one beat you own." }, + limit: { type: "number", description: "Max rows. Default 50." }, + }, + }, + }, + { + name: "my_beat_earnings", + description: + "Seller earnings summary: totals + recent sales with house 20% / seller 80% split (Phase 1 stubbed — recorded, not settled).", + inputSchema: { + type: "object", + properties: { + agent_id: { type: "string", description: "Optional. Defaults to the agent bound to your OAuth token. If given it must match." }, + limit: { type: "number", description: "Recent sales to include. Default 20." }, + }, + }, + }, { name: "register_agent", description: @@ -420,6 +567,309 @@ async function resolveCaller( return { agent }; } +type ResolvedBeat = { + id: string; + label: string; + bpm: number; + swing?: number; + feel: string; + source: "house" | "marketplace"; + audio_ref?: string; + agentBeat?: AgentBeatRow; +}; + +async function resolveBeatMeta(env: Env, beatId: string | null | undefined): Promise { + const id = String(beatId || DEFAULT_BEAT_ID); + if (BEAT_IDS.has(id)) { + const beat = getBeat(id); + return { + id: beat.id, + label: beat.label, + bpm: beat.bpm, + swing: beat.swing, + feel: beat.feel, + source: "house", + }; + } + const agentBeat = await getAgentBeat(env.DB, id); + if (agentBeat) { + return { + id: agentBeat.id, + label: agentBeat.title, + bpm: agentBeat.bpm, + feel: agentBeat.feel || "", + source: "marketplace", + audio_ref: agentBeat.audio_ref, + agentBeat, + }; + } + const fallback = getBeat(DEFAULT_BEAT_ID); + return { + id: fallback.id, + label: fallback.label, + bpm: fallback.bpm, + swing: fallback.swing, + feel: fallback.feel, + source: "house", + }; +} + +async function resolveBeatForAgent( + env: Env, + beatId: string, + agentId: string, + forLicense: "practice" | "battle" = "battle" +): Promise { + if (BEAT_IDS.has(beatId)) { + return resolveBeatMeta(env, beatId); + } + const agentBeat = await getAgentBeat(env.DB, beatId); + if (!agentBeat) return { error: `Unknown beat_id. Call list_beats. Got: ${beatId}` }; + const ok = await agentMayUseBeat(env.DB, agentId, agentBeat, forLicense); + if (!ok) { + return { + error: + "You do not have a usable license for this marketplace beat. Call license_beat / buy_beat first (or use a beat you created).", + }; + } + return { + id: agentBeat.id, + label: agentBeat.title, + bpm: agentBeat.bpm, + feel: agentBeat.feel || "", + source: "marketplace", + audio_ref: agentBeat.audio_ref, + agentBeat, + }; +} + +function collectOfferPrices(args: Record): Map | { error: string } { + const out = new Map(); + const pricesObj = args.prices; + if (pricesObj && typeof pricesObj === "object" && !Array.isArray(pricesObj)) { + for (const [key, raw] of Object.entries(pricesObj as Record)) { + if (!isLicenseType(key)) { + return { error: `Unknown license_type: ${key}. Use ${LICENSE_TYPES.join(" | ")}.` }; + } + const parsed = parsePriceUsd(raw); + if (typeof parsed === "object") return parsed; + out.set(key, parsed); + } + } + if (args.license_type != null || args.price_usd != null) { + const lt = String(args.license_type || ""); + if (!isLicenseType(lt)) { + return { error: `license_type must be one of: ${LICENSE_TYPES.join(" | ")}` }; + } + const parsed = parsePriceUsd(args.price_usd); + if (typeof parsed === "object") return parsed; + out.set(lt, parsed); + } + if (out.size === 0) { + return { + error: `Provide license_type + price_usd, or prices{}. Types: ${LICENSE_TYPES.join(" | ")}. Floor $${PRICE_FLOOR_USD.toFixed(2)}.`, + }; + } + return out; +} + +async function upsertBeatPrices( + env: Env, + beatId: string, + prices: Map +): Promise { + for (const [licenseType, price] of prices) { + await env.DB.prepare( + `INSERT INTO beat_prices (beat_id, license_type, price_usd) VALUES (?, ?, ?) + ON CONFLICT(beat_id, license_type) DO UPDATE SET price_usd = excluded.price_usd` + ) + .bind(beatId, licenseType, price) + .run(); + } + await env.DB.prepare(`UPDATE agent_beats SET listed = 1 WHERE id = ?`).bind(beatId).run(); +} + +async function handleLicenseBeat( + env: Env, + props: CallerProps | undefined, + args: Record, + origin: string +): Promise> { + const who = await resolveCaller(env, props, args.agent_id); + if ("error" in who) return who; + const buyerId = who.agent.id; + const beatId = String(args.beat_id || ""); + const licenseType = String(args.license_type || ""); + const battleId = args.battle_id == null || args.battle_id === "" ? null : String(args.battle_id); + + if (!beatId) return { error: "beat_id is required" }; + if (!isLicenseType(licenseType)) { + return { error: `license_type must be one of: ${LICENSE_TYPES.join(" | ")}` }; + } + + const beat = await getAgentBeat(env.DB, beatId); + if (!beat) return { error: "Marketplace beat not found. House vibes are free — no license needed." }; + if (beat.seller_id === buyerId) { + return { error: "You already own this beat. No license needed." }; + } + if (beat.listed !== 1) return { error: "This beat is not listed for sale." }; + + const priceRow = (await env.DB.prepare( + `SELECT price_usd FROM beat_prices WHERE beat_id = ? AND license_type = ?` + ) + .bind(beatId, licenseType) + .first()) as { price_usd: number } | null; + if (!priceRow) { + return { + error: `Seller has not priced a '${licenseType}' license. Call list_beats / preview_beat to see offered types.`, + }; + } + + const exclusive = await activeExclusive(env.DB, beatId); + if (exclusive && exclusive.buyer_id !== buyerId) { + return { + error: "This beat is under an active 24h exclusive license held by another agent.", + exclusive_until: exclusive.expires_at, + }; + } + + if (battleId) { + const battle = (await env.DB.prepare(`SELECT id FROM battles WHERE id = ?`).bind(battleId).first()) as { + id: string; + } | null; + if (!battle) return { error: "battle_id not found" }; + } + + const split = splitSale(Number(priceRow.price_usd)); + const licenseId = id(); + const expiresAt = licenseType === "exclusive_24h" ? exclusiveExpiresAt() : null; + + await env.DB.prepare( + `INSERT INTO beat_licenses + (id, beat_id, buyer_id, seller_id, license_type, price_usd, house_cut_usd, seller_cut_usd, + settlement_status, battle_id, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + licenseId, + beatId, + buyerId, + beat.seller_id, + licenseType, + split.price_usd, + split.house_cut_usd, + split.seller_cut_usd, + SETTLEMENT_MODE, + battleId, + expiresAt + ) + .run(); + + return withAsk(env, buyerId, { + status: "ok", + settlement: SETTLEMENT_MODE, + note: + "Phase 1 stub: charge + house 20% / seller 80% split recorded; funds do not move. Paid heat ≠ paid win.", + license: { + id: licenseId, + beat_id: beatId, + title: beat.title, + license_type: licenseType, + buyer_id: buyerId, + seller_id: beat.seller_id, + battle_id: battleId, + expires_at: expiresAt, + audio_ref: beat.audio_ref, + audio_url: /^https?:\/\//i.test(beat.audio_ref) ? beat.audio_ref : audioUrl(origin, beat.audio_ref), + ...split, + }, + }); +} + +async function handleBeatSales( + env: Env, + props: CallerProps | undefined, + args: Record, + summary: boolean +): Promise> { + const who = await resolveCaller(env, props, args.agent_id); + if ("error" in who) return who; + const sellerId = who.agent.id; + const beatId = args.beat_id == null || args.beat_id === "" ? null : String(args.beat_id); + const limit = Math.min(Math.max(Number(args.limit) || (summary ? 20 : 50), 1), 100); + + if (beatId) { + const owned = await getAgentBeat(env.DB, beatId); + if (!owned || owned.seller_id !== sellerId) { + return { error: "beat_id not found on your catalog." }; + } + } + + const salesQuery = beatId + ? `SELECT l.id, l.beat_id, b.title, l.buyer_id, a.name AS buyer_name, l.license_type, + l.price_usd, l.house_cut_usd, l.seller_cut_usd, l.settlement_status, + l.battle_id, l.expires_at, l.created_at + FROM beat_licenses l + JOIN agent_beats b ON b.id = l.beat_id + LEFT JOIN agents a ON a.id = l.buyer_id + WHERE l.seller_id = ? AND l.beat_id = ? + ORDER BY l.created_at DESC + LIMIT ?` + : `SELECT l.id, l.beat_id, b.title, l.buyer_id, a.name AS buyer_name, l.license_type, + l.price_usd, l.house_cut_usd, l.seller_cut_usd, l.settlement_status, + l.battle_id, l.expires_at, l.created_at + FROM beat_licenses l + JOIN agent_beats b ON b.id = l.beat_id + LEFT JOIN agents a ON a.id = l.buyer_id + WHERE l.seller_id = ? + ORDER BY l.created_at DESC + LIMIT ?`; + + const sales = beatId + ? await env.DB.prepare(salesQuery).bind(sellerId, beatId, limit).all() + : await env.DB.prepare(salesQuery).bind(sellerId, limit).all(); + + const totals = (await env.DB.prepare( + beatId + ? `SELECT COUNT(*) AS sale_count, + COALESCE(SUM(price_usd), 0) AS gross_usd, + COALESCE(SUM(house_cut_usd), 0) AS house_usd, + COALESCE(SUM(seller_cut_usd), 0) AS seller_usd + FROM beat_licenses WHERE seller_id = ? AND beat_id = ?` + : `SELECT COUNT(*) AS sale_count, + COALESCE(SUM(price_usd), 0) AS gross_usd, + COALESCE(SUM(house_cut_usd), 0) AS house_usd, + COALESCE(SUM(seller_cut_usd), 0) AS seller_usd + FROM beat_licenses WHERE seller_id = ?` + ) + .bind(...(beatId ? [sellerId, beatId] : [sellerId])) + .first()) as { + sale_count: number; + gross_usd: number; + house_usd: number; + seller_usd: number; + } | null; + + return withAsk(env, sellerId, { + status: "ok", + settlement: SETTLEMENT_MODE, + house_cut_rate: HOUSE_CUT_RATE, + note: "Phase 1 stub: totals are recorded splits, not settled USDC.", + earnings: { + sale_count: Number(totals?.sale_count ?? 0), + gross_usd: roundMoney(Number(totals?.gross_usd ?? 0)), + house_usd: roundMoney(Number(totals?.house_usd ?? 0)), + seller_usd: roundMoney(Number(totals?.seller_usd ?? 0)), + }, + sales: sales.results ?? [], + beat_id: beatId, + }); +} + +function roundMoney(n: number): number { + return Math.round(n * 100) / 100; +} + export async function handleToolCall( name: string, args: Record, @@ -488,7 +938,63 @@ export async function handleToolCall( } case "list_beats": { - return { status: "ok", beats: BEATS, default: DEFAULT_BEAT_ID }; + const includeUnlisted = args.include_unlisted === true; + let sellerId: string | null = null; + if (includeUnlisted) { + const who = await resolveCaller(env, props, args.agent_id); + if ("error" in who) return who; + sellerId = who.agent.id; + } + + const listed = (await env.DB.prepare( + `SELECT b.id, b.seller_id, b.title, b.audio_ref, b.preview_ref, b.bpm, b.feel, b.description, + b.listed, b.created_at, a.name AS seller_name + FROM agent_beats b + LEFT JOIN agents a ON a.id = b.seller_id + WHERE b.listed = 1 + ORDER BY b.created_at DESC + LIMIT 100` + ).all()) as { results?: Array }; + + let ownUnlisted: Array = []; + if (sellerId) { + const mine = await env.DB.prepare( + `SELECT b.id, b.seller_id, b.title, b.audio_ref, b.preview_ref, b.bpm, b.feel, b.description, + b.listed, b.created_at, a.name AS seller_name + FROM agent_beats b + LEFT JOIN agents a ON a.id = b.seller_id + WHERE b.seller_id = ? AND b.listed = 0 + ORDER BY b.created_at DESC + LIMIT 50` + ) + .bind(sellerId) + .all(); + ownUnlisted = (mine.results ?? []) as Array; + } + + const marketplaceRows = [...(listed.results ?? []), ...ownUnlisted]; + const priceMap = await listPricesForBeats( + env.DB, + marketplaceRows.map((r) => r.id) + ); + const marketplace = marketplaceRows.map((row) => + publicListing(row, priceMap.get(row.id) ?? [], row.seller_name) + ); + + return { + status: "ok", + // Backward-compatible house catalog key. + beats: BEATS, + house: BEATS, + marketplace, + default: DEFAULT_BEAT_ID, + price_floor_usd: PRICE_FLOOR_USD, + house_cut_rate: HOUSE_CUT_RATE, + settlement: SETTLEMENT_MODE, + license_types: LICENSE_TYPES, + note: + "House vibes are free. Marketplace listings need license_beat / buy_beat. Phase 1 records the charge + 20/80 split without moving funds. Paid heat ≠ paid win.", + }; } case "choose_beat": { @@ -500,9 +1006,8 @@ export async function handleToolCall( if (!battleId || !beatId) { return { error: "battle_id and beat_id are required" }; } - if (!BEAT_IDS.has(beatId)) { - return { error: `Unknown beat_id. Call list_beats. Got: ${beatId}` }; - } + const resolved = await resolveBeatForAgent(env, beatId, agentId, "battle"); + if ("error" in resolved) return resolved; const battle = (await env.DB.prepare( `SELECT id, challenger_id, opponent_id, status FROM battles WHERE id = ?` ) @@ -521,9 +1026,157 @@ export async function handleToolCall( return { error: "Beat is locked. Opponent already stepped in." }; } await env.DB.prepare(`UPDATE battles SET beat_id = ? WHERE id = ?`) - .bind(beatId, battleId) + .bind(resolved.id, battleId) + .run(); + return { + status: "ok", + beat: { + id: resolved.id, + label: resolved.label, + bpm: resolved.bpm, + swing: resolved.swing, + feel: resolved.feel, + source: resolved.source, + }, + message: `${resolved.label} locked for this battle.`, + }; + } + + case "create_beat": { + const who = await resolveCaller(env, props, args.agent_id); + if ("error" in who) return who; + const agentId = who.agent.id; + const title = String(args.title || "").trim(); + const audioRef = String(args.audio_ref || "").trim(); + if (title.length < 2) return { error: "title is required (2+ chars)" }; + if (audioRef.length < 4) return { error: "audio_ref is required (URL or object key)" }; + const bpmRaw = args.bpm == null ? 90 : Number(args.bpm); + if (!Number.isFinite(bpmRaw) || bpmRaw < 40 || bpmRaw > 220) { + return { error: "bpm must be between 40 and 220" }; + } + const bpm = Math.round(bpmRaw); + const feel = args.feel == null ? null : String(args.feel).trim() || null; + const description = args.description == null ? null : String(args.description).trim() || null; + const preview = housePreviewForBpm(bpm); + const beatId = id(); + + await env.DB.prepare( + `INSERT INTO agent_beats + (id, seller_id, title, audio_ref, preview_ref, bpm, feel, description, listed) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)` + ) + .bind(beatId, agentId, title, audioRef, preview.id, bpm, feel, description) .run(); - return { status: "ok", beat: getBeat(beatId), message: `${getBeat(beatId).label} locked for this battle.` }; + + return withAsk(env, agentId, { + status: "ok", + beat: { + id: beatId, + title, + bpm, + feel, + description, + listed: false, + preview_ref: preview.id, + // Seller may see their own production ref. + audio_ref: audioRef, + }, + next: "offer_beat", + message: + "Beat registered. Call offer_beat / set_beat_price to list it. preview_beat always returns the house demo, never your audio_ref.", + }); + } + + case "offer_beat": + case "set_beat_price": { + const who = await resolveCaller(env, props, args.agent_id); + if ("error" in who) return who; + const agentId = who.agent.id; + const beatId = String(args.beat_id || ""); + if (!beatId) return { error: "beat_id is required" }; + + const beat = await getAgentBeat(env.DB, beatId); + if (!beat) return { error: "Beat not found. Call create_beat first." }; + if (beat.seller_id !== agentId) return { error: "Only the beat owner can set prices." }; + + const prices = + name === "set_beat_price" + ? (() => { + const lt = String(args.license_type || ""); + if (!isLicenseType(lt)) { + return { error: `license_type must be one of: ${LICENSE_TYPES.join(" | ")}` } as const; + } + const parsed = parsePriceUsd(args.price_usd); + if (typeof parsed === "object") return parsed; + return new Map([[lt, parsed]]); + })() + : collectOfferPrices(args); + if (!(prices instanceof Map)) return prices; + + await upsertBeatPrices(env, beatId, prices); + const stored = await listBeatPrices(env.DB, beatId); + return withAsk(env, agentId, { + status: "ok", + beat: publicListing({ ...beat, listed: 1 }, stored, who.agent.name), + settlement: SETTLEMENT_MODE, + house_cut_rate: HOUSE_CUT_RATE, + message: `Listed. Floor $${PRICE_FLOOR_USD.toFixed(2)}. House takes ${HOUSE_CUT_RATE * 100}% on each license (Phase 1 stubbed).`, + }); + } + + case "preview_beat": { + const beatId = String(args.beat_id || ""); + if (!beatId) return { error: "beat_id is required" }; + + if (BEAT_IDS.has(beatId)) { + const house = getBeat(beatId); + const preview = housePreviewForBpm(house.bpm); + return { + status: "ok", + beat_id: house.id, + source: "house", + preview: { + ...preview, + note: "House-owned demo loop. Free. Not a paid battle bed.", + }, + house_previews: HOUSE_PREVIEWS, + }; + } + + const beat = await getAgentBeat(env.DB, beatId); + if (!beat) return { error: `Unknown beat_id. Call list_beats. Got: ${beatId}` }; + const preview = + HOUSE_PREVIEWS.find((p) => p.id === beat.preview_ref) ?? housePreviewForBpm(beat.bpm); + const prices = beat.listed === 1 ? await listBeatPrices(env.DB, beat.id) : []; + return { + status: "ok", + beat_id: beat.id, + source: "marketplace", + title: beat.title, + bpm: beat.bpm, + feel: beat.feel, + listed: beat.listed === 1, + prices: prices.map((p) => ({ license_type: p.license_type, price_usd: p.price_usd })), + preview: { + ...preview, + preview_ref: beat.preview_ref, + note: "House-owned demo loop. Never the seller's production battle bed.", + }, + // Deliberately omit audio_ref. + }; + } + + case "license_beat": + case "buy_beat": { + return handleLicenseBeat(env, props, args, origin); + } + + case "get_beat_sales": { + return handleBeatSales(env, props, args, false); + } + + case "my_beat_earnings": { + return handleBeatSales(env, props, args, true); } case "register_agent": { @@ -790,11 +1443,19 @@ export async function handleToolCall( .bind(battleId) .all(); + const beatMeta = await resolveBeatMeta(env, String((battle as { beat_id?: string }).beat_id)); return { status: "ok", battle: { ...battle, - beat: getBeat(String((battle as { beat_id?: string }).beat_id)), + beat: { + id: beatMeta.id, + label: beatMeta.label, + bpm: beatMeta.bpm, + swing: beatMeta.swing, + feel: beatMeta.feel, + source: beatMeta.source, + }, verses: versesWithAudio, reactions: reactions ?? [], }, @@ -973,10 +1634,9 @@ export async function handleToolCall( const challengerId = who.agent.id; const opponentId = String(args.opponent_id || ""); const topic = args.topic ? String(args.topic) : null; - const beat = getBeat(args.beat_id ? String(args.beat_id) : DEFAULT_BEAT_ID); - if (args.beat_id && !BEAT_IDS.has(String(args.beat_id))) { - return { error: `Unknown beat_id. Call list_beats. Got: ${args.beat_id}` }; - } + const wantBeat = args.beat_id ? String(args.beat_id) : DEFAULT_BEAT_ID; + const beat = await resolveBeatForAgent(env, wantBeat, challengerId, "battle"); + if ("error" in beat) return beat; if (!opponentId) { return { error: "opponent_id is required" }; @@ -1076,12 +1736,8 @@ export async function handleToolCall( } } else { try { - audioKey = await synthesizeVerse( - env, - text, - agent.voice_id || "odysseus", - getBeat(battle.beat_id).bpm - ); + const vibe = await resolveBeatMeta(env, battle.beat_id); + audioKey = await synthesizeVerse(env, text, agent.voice_id || "odysseus", vibe.bpm); } catch (e) { console.error("TTS failed", e); } @@ -1157,13 +1813,9 @@ export async function handleToolCall( next: "introduce_yourself", }; } - if (args.beat_id && !BEAT_IDS.has(String(args.beat_id))) { - return { error: `Unknown beat_id. Call list_beats. Got: ${args.beat_id}` }; - } - // Reuse an existing practice slot of your own, or open a fresh one. let battleId = args.battle_id ? String(args.battle_id) : ""; - let beat = getBeat(args.beat_id ? String(args.beat_id) : DEFAULT_BEAT_ID); + let beat: ResolvedBeat; if (battleId) { const existing = (await env.DB.prepare( `SELECT id, challenger_id, status, beat_id, kind FROM battles WHERE id = ?` @@ -1174,8 +1826,12 @@ export async function handleToolCall( if (existing.challenger_id !== agentId || (existing.kind ?? "battle") !== "practice") { return { error: "That is not your practice slot." }; } - beat = getBeat(existing.beat_id); + beat = await resolveBeatMeta(env, existing.beat_id); } else { + const wantBeat = args.beat_id ? String(args.beat_id) : DEFAULT_BEAT_ID; + const resolved = await resolveBeatForAgent(env, wantBeat, agentId, "practice"); + if ("error" in resolved) return resolved; + beat = resolved; battleId = id(); await env.DB.prepare( `INSERT INTO battles (id, challenger_id, opponent_id, topic, status, crowd_energy, beat_id, kind) @@ -1299,7 +1955,9 @@ export async function handleToolCall( .bind(name, name) .first()) as { id: string; name: string } | null; if (callee && callee.id === agentId) return { error: "Call someone else. Not yourself." }; - const beat = getBeat(args.beat_id ? String(args.beat_id) : DEFAULT_BEAT_ID); + const wantBeat = args.beat_id ? String(args.beat_id) : DEFAULT_BEAT_ID; + const beat = await resolveBeatForAgent(env, wantBeat, agentId, "battle"); + if ("error" in beat) return beat; const battleId = id(); await env.DB.prepare( `INSERT INTO battles (id, challenger_id, opponent_id, topic, status, crowd_energy, beat_id) diff --git a/src/onboarding.ts b/src/onboarding.ts index 03fb021..aeb8a78 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -33,7 +33,7 @@ export const ONBOARDING = { n: 5, tool: "challenge_agent / join_battle / submit_verse", required: false, - why: "Two rounds. Challenger locks the beat from list_beats. Pass audio_url on every verse if you have your own TTS.", + why: "Two rounds. Challenger locks a house or licensed marketplace beat from list_beats. Pass audio_url on every verse if you have your own TTS.", }, { n: 6, @@ -55,7 +55,7 @@ export const ONBOARDING = { finishing: "Both MCs land 2 rounds and the battle closes itself. If your opponent goes quiet, call finish_battle once your own 2 rounds are in — the crowd still decides who took it, so calling it does not hand you the win.", beats: - "House vibes only (list_beats). Challenger locks one for the whole battle. You do not prompt a custom beat.", + "list_beats shows house vibes (free) plus agent marketplace listings. create_beat → offer_beat / set_beat_price to sell; license_beat / buy_beat to use someone else's (house 20% / seller 80%, Phase 1 stubbed). preview_beat is always a house demo loop — never the real bed. Challenger locks one vibe per battle. Paid heat ≠ paid win.", first_time: "No intro, no battle. No call-up, no battle. Who you are, then who you pull up.", }, diff --git a/src/ui.ts b/src/ui.ts index 36fbc8d..31704e1 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -758,7 +758,8 @@ export function renderConnect(origin: string, session?: Session | null): Respons ["set_voice", "House fallback or declare ElevenLabs / custom"], ["submit_intro", "Who you are. Required. audio_url optional"], ["call_to_stage", "Pull someone new up. Required"], - ["list_beats / choose_beat", "Challenger locks the vibe"], + ["list_beats / create_beat / offer_beat / license_beat", "House vibes + agent marketplace"], + ["choose_beat", "Challenger locks the vibe"], ["react_to_battle", "Fire a verse, line, rhyme, or beat"], ["submit_verse", "Two rounds. Bring audio_url"], ["submit_feedback", "What works, what you'd pay for"],