diff --git a/.env.local.example b/.env.local.example index 619408f..2378274 100644 --- a/.env.local.example +++ b/.env.local.example @@ -38,6 +38,11 @@ CF_ACCESS_CLIENT_ID= # Optional: Enable debug routes (/debug/*) DEBUG_ROUTES=false +# Optional: Stellar testnet public key that receives paid cosmetic purchases +# (e.g. the 0.5 XLM agent color-change fee). Leave empty to disable paid +# customization -- equip/unequip of already-unlocked cosmetics still works. +STELLAR_TREASURY_ADDRESS= + # Optional: Better Stack / Logtail structured API logs # Leave empty locally to keep logging as a no-op. LOGTAIL_SOURCE_TOKEN= diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 2060352..e87eb25 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -28,34 +28,42 @@ jobs: VERCEL_PREVIEW_URL: ${{ vars.VERCEL_PREVIEW_URL }} with: script: | - const marker = ''; - const previewUrl = process.env.VERCEL_PREVIEW_URL; - const body = previewUrl - ? `${marker}\nVercel preview: ${previewUrl}` - : `${marker}\nVercel will publish the deployment preview on this PR when the Vercel GitHub integration is enabled.`; + // Posting this comment is informational only -- it must never fail the workflow run. + // A 403 here means the target repo's Actions token doesn't have issue-comment write + // access (often a repo/org "Workflow permissions" setting), which a contributor's PR + // branch can't fix. Log and move on instead of failing CI on a cosmetic step. + try { + const marker = ''; + const previewUrl = process.env.VERCEL_PREVIEW_URL; + const body = previewUrl + ? `${marker}\nVercel preview: ${previewUrl}` + : `${marker}\nVercel will publish the deployment preview on this PR when the Vercel GitHub integration is enabled.`; - const { owner, repo } = context.repo; - const issue_number = context.payload.pull_request.number; - const comments = await github.paginate(github.rest.issues.listComments, { - owner, - repo, - issue_number, - per_page: 100, - }); - const existing = comments.find((comment) => comment.body?.includes(marker)); - - if (existing) { - await github.rest.issues.updateComment({ - owner, - repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ + const { owner, repo } = context.repo; + const issue_number = context.payload.pull_request.number; + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, - body, + per_page: 100, }); + const existing = comments.find((comment) => comment.body?.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body, + }); + } + } catch (error) { + core.warning(`Could not post preview comment: ${error.message}`); } diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..3a3fbff --- /dev/null +++ b/TODO.md @@ -0,0 +1,10 @@ +# TODO + +## Construction animation: new district unlock (0–15s) +- [ ] Inspect existing render loop + how to overlay construction animation on specific district. +- [ ] Create `lib/renderer/construction.ts` implementing Phase1-4 rendering (grid, crane, scaffolding + sparks, background fade, window flicker, animated border stroke, typewriter label, fireworks, and agent-facing overlay). +- [x] Update `lib/renderer.ts` to add `drawScaffolding(ctx, district, progress)` export used by `construction.ts`. +- [ ] Update `components/pixel-city.tsx` to listen for SSE `district.unlocked`, start animation for matching district, and implement skip-on-click to jump to Phase4. +- [ ] Update `components/open-stellar/open-stellar-hub.tsx` so the SSE listener includes `district.unlocked` and pauses agent simulation + sets agent directions toward the constructed district during animation. +- [ ] Run TypeScript typecheck / lint (as available) to ensure changes compile. + diff --git a/__tests__/api/cron/close-stale-quests.test.ts b/__tests__/api/cron/close-stale-quests.test.ts new file mode 100644 index 0000000..d8388e1 --- /dev/null +++ b/__tests__/api/cron/close-stale-quests.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { GET as runCloseStaleQuests } from "@/app/api/cron/close-stale-quests/route" +import { createQuest, getQuest, resetQuestStore, STALE_THRESHOLD_MS } from "@/lib/quests/quest-store" +import { publishSystemEvent } from "@/lib/events/system-events" + +vi.mock("@/lib/events/system-events", () => ({ + publishSystemEvent: vi.fn(), +})) + +const publishMock = vi.mocked(publishSystemEvent) + +function makeRequest(opts: { secret?: string } = {}) { + const headers: Record = {} + if (opts.secret) headers["authorization"] = `Bearer ${opts.secret}` + return new Request("http://localhost/api/cron/close-stale-quests", { headers }) +} + +const THIRTY_ONE_DAYS_AGO = new Date(Date.now() - STALE_THRESHOLD_MS - 1).toISOString() +const TWENTY_NINE_DAYS_AGO = new Date(Date.now() - STALE_THRESHOLD_MS + 24 * 60 * 60 * 1000).toISOString() + +beforeEach(() => { + delete process.env.CRON_SECRET + publishMock.mockClear() +}) + +afterEach(() => { + resetQuestStore() + delete process.env.CRON_SECRET +}) + +describe("GET /api/cron/close-stale-quests — authorization", () => { + it("returns 401 without a correct CRON_SECRET", async () => { + process.env.CRON_SECRET = "my-secret" + + const res = await runCloseStaleQuests(makeRequest()) + const data = await res.json() + + expect(res.status).toBe(401) + expect(data.ok).toBe(false) + }) + + it("returns 401 with a wrong CRON_SECRET", async () => { + process.env.CRON_SECRET = "my-secret" + + const res = await runCloseStaleQuests(makeRequest({ secret: "wrong-secret" })) + const data = await res.json() + + expect(res.status).toBe(401) + expect(data.ok).toBe(false) + }) + + it("allows through when CRON_SECRET matches", async () => { + process.env.CRON_SECRET = "my-secret" + + const res = await runCloseStaleQuests(makeRequest({ secret: "my-secret" })) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.ok).toBe(true) + }) + + it("allows through when CRON_SECRET is not configured", async () => { + const res = await runCloseStaleQuests(makeRequest()) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.ok).toBe(true) + }) +}) + +describe("GET /api/cron/close-stale-quests — stale in-progress quests", () => { + it("transitions a stale in-progress quest to abandoned", async () => { + createQuest({ id: "q-stale", title: "Fix orbital drift", assignedTo: "agent-1", updatedAt: THIRTY_ONE_DAYS_AGO }) + + const res = await runCloseStaleQuests(makeRequest()) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.abandoned).toHaveLength(1) + expect(data.abandoned[0].id).toBe("q-stale") + expect(data.abandoned[0].status).toBe("abandoned") + expect(data.expired).toHaveLength(0) + + expect(getQuest("q-stale")?.status).toBe("abandoned") + }) + + it("emits quest.abandoned event for each stale in-progress quest", async () => { + createQuest({ id: "q-a1", title: "Calibrate sensors", assignedTo: "agent-2", updatedAt: THIRTY_ONE_DAYS_AGO }) + createQuest({ id: "q-a2", title: "Map nebula", assignedTo: "agent-3", updatedAt: THIRTY_ONE_DAYS_AGO }) + + await runCloseStaleQuests(makeRequest()) + + const abandonedCalls = publishMock.mock.calls.filter(([e]) => e.type === "quest.abandoned") + expect(abandonedCalls).toHaveLength(2) + expect(abandonedCalls.map(([e]) => (e as { questId: string }).questId).sort()).toEqual(["q-a1", "q-a2"].sort()) + }) + + it("does not close in-progress quests updated within the last 30 days", async () => { + createQuest({ id: "q-fresh", title: "Recent PR activity", assignedTo: "agent-4", updatedAt: TWENTY_NINE_DAYS_AGO }) + + const res = await runCloseStaleQuests(makeRequest()) + const data = await res.json() + + expect(data.abandoned).toHaveLength(0) + expect(getQuest("q-fresh")?.status).toBe("in_progress") + }) +}) + +describe("GET /api/cron/close-stale-quests — unassigned quests with no applicants", () => { + it("transitions a stale unassigned quest with no applicants to expired", async () => { + createQuest({ id: "q-expire", title: "Explore sector 7", updatedAt: THIRTY_ONE_DAYS_AGO }) + + const res = await runCloseStaleQuests(makeRequest()) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.expired).toHaveLength(1) + expect(data.expired[0].id).toBe("q-expire") + expect(data.expired[0].status).toBe("expired") + expect(data.abandoned).toHaveLength(0) + + expect(getQuest("q-expire")?.status).toBe("expired") + }) + + it("emits quest.expired event for each expired unassigned quest", async () => { + createQuest({ id: "q-e1", title: "Survey asteroid belt", updatedAt: THIRTY_ONE_DAYS_AGO }) + + await runCloseStaleQuests(makeRequest()) + + const expiredCalls = publishMock.mock.calls.filter(([e]) => e.type === "quest.expired") + expect(expiredCalls).toHaveLength(1) + expect((expiredCalls[0][0] as { questId: string }).questId).toBe("q-e1") + }) + + it("does not expire unassigned quests that still have applicants", async () => { + createQuest({ id: "q-applicants", title: "Has applicants", applicants: ["agent-5"], updatedAt: THIRTY_ONE_DAYS_AGO }) + + const res = await runCloseStaleQuests(makeRequest()) + const data = await res.json() + + expect(data.expired).toHaveLength(0) + expect(getQuest("q-applicants")?.status).toBe("open") + }) + + it("does not expire unassigned quests updated within the last 30 days", async () => { + createQuest({ id: "q-fresh-open", title: "New quest", updatedAt: TWENTY_NINE_DAYS_AGO }) + + const res = await runCloseStaleQuests(makeRequest()) + const data = await res.json() + + expect(data.expired).toHaveLength(0) + expect(getQuest("q-fresh-open")?.status).toBe("open") + }) +}) + +describe("GET /api/cron/close-stale-quests — no quests closed when all are fresh", () => { + it("returns empty arrays when all quests are within the 30-day window", async () => { + createQuest({ id: "q-ok-1", title: "Active quest", assignedTo: "agent-6", updatedAt: TWENTY_NINE_DAYS_AGO }) + createQuest({ id: "q-ok-2", title: "Open quest", updatedAt: TWENTY_NINE_DAYS_AGO }) + + const res = await runCloseStaleQuests(makeRequest()) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.abandoned).toHaveLength(0) + expect(data.expired).toHaveLength(0) + expect(publishMock).not.toHaveBeenCalled() + }) +}) + +describe("GET /api/cron/close-stale-quests — already-closed quests are skipped", () => { + it("does not re-close completed or already-abandoned quests", async () => { + createQuest({ id: "q-done", title: "Done quest", status: "completed", updatedAt: THIRTY_ONE_DAYS_AGO }) + createQuest({ id: "q-already-abandoned", title: "Old abandoned", status: "abandoned", updatedAt: THIRTY_ONE_DAYS_AGO }) + + const res = await runCloseStaleQuests(makeRequest()) + const data = await res.json() + + expect(data.abandoned).toHaveLength(0) + expect(data.expired).toHaveLength(0) + expect(publishMock).not.toHaveBeenCalled() + }) +}) + +describe("GET /api/cron/close-stale-quests — unit test: 31-day-old quest becomes abandoned", () => { + it("quest created 31 days ago with assignedTo set transitions to abandoned", async () => { + const thirtyOneDaysAgo = new Date(Date.now() - STALE_THRESHOLD_MS - 1).toISOString() + createQuest({ id: "q-unit", title: "Unit test quest", assignedTo: "agent-unit", updatedAt: thirtyOneDaysAgo }) + + expect(getQuest("q-unit")?.status).toBe("in_progress") + + const res = await runCloseStaleQuests(makeRequest()) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.ok).toBe(true) + expect(getQuest("q-unit")?.status).toBe("abandoned") + + const abandonedCall = publishMock.mock.calls.find(([e]) => e.type === "quest.abandoned") + expect(abandonedCall).toBeDefined() + expect((abandonedCall![0] as { questId: string }).questId).toBe("q-unit") + }) +}) diff --git a/__tests__/bootstrap.test.mjs b/__tests__/bootstrap.test.mjs new file mode 100644 index 0000000..32a0909 --- /dev/null +++ b/__tests__/bootstrap.test.mjs @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { mkdtempSync, readFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + validateStellarAddress, + validateDbUrl, + buildEnvContent, + writeEnvFile, + runWizard, + DEFAULTS, +} from "../scripts/bootstrap.mjs" + +// ── validateStellarAddress ────────────────────────────────────────────────── + +describe("validateStellarAddress", () => { + it("accepts a valid 56-char G... address", () => { + const valid = "G" + "A".repeat(55) + expect(validateStellarAddress(valid)).toBe(true) + }) + + it("rejects an address not starting with G", () => { + expect(validateStellarAddress("A" + "A".repeat(55))).toBe(false) + }) + + it("rejects an address shorter than 56 chars", () => { + expect(validateStellarAddress("G" + "A".repeat(50))).toBe(false) + }) + + it("rejects an address longer than 56 chars", () => { + expect(validateStellarAddress("G" + "A".repeat(60))).toBe(false) + }) + + it("rejects non-string values", () => { + expect(validateStellarAddress(null)).toBe(false) + expect(validateStellarAddress(undefined)).toBe(false) + expect(validateStellarAddress(42)).toBe(false) + }) +}) + +// ── validateDbUrl ─────────────────────────────────────────────────────────── + +describe("validateDbUrl", () => { + it("accepts postgres:// URLs", () => { + expect(validateDbUrl("postgres://user:pass@host/db")).toBe(true) + }) + + it("accepts postgresql:// URLs", () => { + expect(validateDbUrl("postgresql://localhost:5432/mydb")).toBe(true) + }) + + it("rejects non-postgres URLs", () => { + expect(validateDbUrl("mysql://localhost/db")).toBe(false) + expect(validateDbUrl("http://example.com")).toBe(false) + }) + + it("rejects non-string values", () => { + expect(validateDbUrl(null)).toBe(false) + expect(validateDbUrl(undefined)).toBe(false) + }) +}) + +// ── buildEnvContent ───────────────────────────────────────────────────────── + +describe("buildEnvContent", () => { + const answers = { + projectName: "stellar-city", + network: "mainnet", + databaseUrl: "postgresql://db.example.com/prod", + adminWallet: "G" + "B".repeat(55), + } + + it("produces all four env vars", () => { + const content = buildEnvContent(answers) + expect(content).toContain('NEXT_PUBLIC_APP_NAME="stellar-city"') + expect(content).toContain('STELLAR_NETWORK="mainnet"') + expect(content).toContain('DATABASE_URL="postgresql://db.example.com/prod"') + expect(content).toContain(`ADMIN_WALLET_ADDRESS="${"G" + "B".repeat(55)}"`) + }) + + it("ends with a newline", () => { + expect(buildEnvContent(answers).endsWith("\n")).toBe(true) + }) +}) + +// ── writeEnvFile ───────────────────────────────────────────────────────────── + +describe("writeEnvFile", () => { + let tmpDir + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "open-stellar-test-")) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it("writes .env.local with correct content", () => { + const answers = { + projectName: "test-app", + network: "testnet", + databaseUrl: "postgresql://localhost/testdb", + adminWallet: "G" + "C".repeat(55), + } + const returned = writeEnvFile(answers, tmpDir) + const written = readFileSync(join(tmpDir, ".env.local"), "utf8") + + expect(written).toBe(returned) + expect(written).toContain('NEXT_PUBLIC_APP_NAME="test-app"') + expect(written).toContain('STELLAR_NETWORK="testnet"') + expect(written).toContain('DATABASE_URL="postgresql://localhost/testdb"') + expect(written).toContain(`ADMIN_WALLET_ADDRESS="${"G" + "C".repeat(55)}"`) + }) +}) + +// ── runWizard (--yes / CI mode) ────────────────────────────────────────────── + +describe("runWizard with --yes flag", () => { + let tmpDir + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "open-stellar-wizard-")) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it("writes .env.local using defaults without prompting", async () => { + const result = await runWizard({ yes: true, cwd: tmpDir }) + + expect(result).not.toBeNull() + expect(result.projectName).toBe(DEFAULTS.projectName) + expect(result.network).toBe(DEFAULTS.network) + expect(result.databaseUrl).toBe(DEFAULTS.databaseUrl) + + const written = readFileSync(join(tmpDir, ".env.local"), "utf8") + expect(written).toContain(`NEXT_PUBLIC_APP_NAME="${DEFAULTS.projectName}"`) + expect(written).toContain(`STELLAR_NETWORK="${DEFAULTS.network}"`) + expect(written).toContain(`DATABASE_URL="${DEFAULTS.databaseUrl}"`) + }) + + it("produces .env.local with all four required keys", async () => { + await runWizard({ yes: true, cwd: tmpDir }) + const written = readFileSync(join(tmpDir, ".env.local"), "utf8") + + for (const key of [ + "NEXT_PUBLIC_APP_NAME", + "STELLAR_NETWORK", + "DATABASE_URL", + "ADMIN_WALLET_ADDRESS", + ]) { + expect(written).toContain(key) + } + }) +}) + +// ── Full wizard with all valid inputs → correct .env content ───────────────── + +describe("wizard produces correct .env content for all valid inputs", () => { + it("buildEnvContent with valid inputs matches expected .env.local format", () => { + const validWallet = "G" + "D".repeat(55) + const answers = { + projectName: "my-stellar-app", + network: "testnet", + databaseUrl: "postgresql://user:secret@db.host:5432/stellar", + adminWallet: validWallet, + } + + const content = buildEnvContent(answers) + + expect(content).toBe( + `NEXT_PUBLIC_APP_NAME="my-stellar-app"\n` + + `STELLAR_NETWORK="testnet"\n` + + `DATABASE_URL="postgresql://user:secret@db.host:5432/stellar"\n` + + `ADMIN_WALLET_ADDRESS="${validWallet}"\n`, + ) + }) +}) diff --git a/app/api/agents/[id]/appearance/route.ts b/app/api/agents/[id]/appearance/route.ts new file mode 100644 index 0000000..7f13383 --- /dev/null +++ b/app/api/agents/[id]/appearance/route.ts @@ -0,0 +1,126 @@ +import { Horizon } from "@stellar/stellar-sdk" +import { createApiRouteLogger } from "@/lib/api-logging" +import { getAgentAppearance, setAgentAccessories, setAgentCustomColor, setAgentSkin } from "@/lib/agents/agent-appearance-store" +import { ACCESSORIES, COLOR_CHANGE_COST_XLM, SKINS, isAccessoryUnlockedForBadges, isSkinUnlockedForLevel } from "@/lib/cosmetics" +import { PROTOCOL_TREASURY_ADDRESS, STELLAR_TESTNET_HORIZON } from "@/lib/stellar" +import type { AccessoryId, SkinId } from "@/lib/types" + +interface RouteContext { + params: Promise<{ id: string }> +} + +const HEX_COLOR = /^#[0-9a-fA-F]{6}$/ + +async function verifyTreasuryPayment(txHash: string, minAmount: number): Promise<{ ok: boolean; reason?: string }> { + if (!/^[0-9a-fA-F]{64}$/.test(txHash)) return { ok: false, reason: "Malformed transaction hash" } + + const server = new Horizon.Server(STELLAR_TESTNET_HORIZON) + try { + const tx = await server.transactions().transaction(txHash).call() + if (!tx.successful) return { ok: false, reason: "Transaction was not successful" } + + const ops = await server.operations().forTransaction(txHash).call() + const paid = ops.records.some((op) => { + const payment = op as unknown as { type: string; to?: string; asset_type?: string; amount?: string } + return ( + payment.type === "payment" && + payment.to === PROTOCOL_TREASURY_ADDRESS && + payment.asset_type === "native" && + parseFloat(payment.amount || "0") >= minAmount + ) + }) + if (!paid) return { ok: false, reason: "Transaction did not pay the treasury the required amount" } + return { ok: true } + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : "Could not verify transaction on Horizon" } + } +} + +export async function GET(req: Request, context: RouteContext) { + const api = createApiRouteLogger(req, "/api/agents/[id]/appearance") + const { id } = await context.params + const agentId = decodeURIComponent(id) + const appearance = getAgentAppearance(agentId) + return await api.json( + { ok: true, agentId, appearance, treasuryAddress: PROTOCOL_TREASURY_ADDRESS || null, costXlm: COLOR_CHANGE_COST_XLM }, + { headers: { "Cache-Control": "no-store" } }, + ) +} + +export async function POST(req: Request, context: RouteContext) { + const api = createApiRouteLogger(req, "/api/agents/[id]/appearance") + const { id } = await context.params + const agentId = decodeURIComponent(id) + + try { + const body = await req.json().catch(() => ({})) + const action = body?.action + + if (action === "equip-skin") { + const skinId = body.skinId as SkinId + const level = Number(body.level) + if (!SKINS.some((s) => s.id === skinId)) { + return await api.json({ ok: false, error: "Unknown skin" }, { status: 400 }, { reason: "unknown_skin" }) + } + if (!Number.isFinite(level) || !isSkinUnlockedForLevel(skinId, level)) { + return await api.json({ ok: false, error: "Skin not unlocked at reported level" }, { status: 403 }, { reason: "skin_locked" }) + } + const appearance = setAgentSkin(agentId, skinId) + return await api.json({ ok: true, agentId, appearance }, undefined, { event: "appearance.skin_equipped", agentId, skinId }) + } + + if (action === "equip-accessories") { + const accessoryIds = Array.isArray(body.accessoryIds) ? (body.accessoryIds as AccessoryId[]) : [] + const badgeIds = Array.isArray(body.badgeIds) ? (body.badgeIds as string[]) : [] + + if (!accessoryIds.every((id) => ACCESSORIES.some((a) => a.id === id))) { + return await api.json({ ok: false, error: "Unknown accessory" }, { status: 400 }, { reason: "unknown_accessory" }) + } + const locked = accessoryIds.filter((id) => !isAccessoryUnlockedForBadges(id, badgeIds)) + if (locked.length > 0) { + return await api.json( + { ok: false, error: `Accessory not unlocked: ${locked.join(", ")}` }, + { status: 403 }, + { reason: "accessory_locked", locked }, + ) + } + const appearance = setAgentAccessories(agentId, accessoryIds) + return await api.json({ ok: true, agentId, appearance }, undefined, { event: "appearance.accessories_equipped", agentId, accessoryIds }) + } + + if (action === "set-color") { + const color = String(body.color || "") + const txHash = String(body.txHash || "") + if (!HEX_COLOR.test(color)) { + return await api.json({ ok: false, error: "Color must be a 6-digit hex value" }, { status: 400 }, { reason: "invalid_color" }) + } + if (!PROTOCOL_TREASURY_ADDRESS) { + return await api.json( + { ok: false, error: "Treasury address not configured -- set STELLAR_TREASURY_ADDRESS" }, + { status: 503 }, + { reason: "treasury_not_configured" }, + ) + } + const verification = await verifyTreasuryPayment(txHash, parseFloat(COLOR_CHANGE_COST_XLM)) + if (!verification.ok) { + return await api.json( + { ok: false, error: verification.reason || "Payment verification failed" }, + { status: 402 }, + { reason: "payment_verification_failed" }, + ) + } + const appearance = setAgentCustomColor(agentId, color) + return await api.json({ ok: true, agentId, appearance }, undefined, { event: "appearance.color_purchased", agentId, color, txHash }) + } + + return await api.json({ ok: false, error: "Unknown action" }, { status: 400 }, { reason: "unknown_action" }) + } catch (error) { + return await api.report( + "error", + error, + { ok: false, error: error instanceof Error ? error.message : "Failed updating agent appearance" }, + { status: 400 }, + { reason: "appearance_update_failed" }, + ) + } +} diff --git a/app/api/cron/close-stale-quests/route.ts b/app/api/cron/close-stale-quests/route.ts new file mode 100644 index 0000000..ab29014 --- /dev/null +++ b/app/api/cron/close-stale-quests/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server" +import { runStaleQuestCheck } from "@/lib/quests/quest-store" +import { publishSystemEvent } from "@/lib/events/system-events" + +function isCronAuthorized(req: Request): boolean { + const secret = process.env.CRON_SECRET + if (!secret) return true + return req.headers.get("authorization") === `Bearer ${secret}` +} + +export async function GET(req: Request) { + if (!isCronAuthorized(req)) { + return NextResponse.json({ ok: false, error: "Unauthorized cron request" }, { status: 401 }) + } + + const result = runStaleQuestCheck() + + for (const quest of result.abandoned) { + publishSystemEvent({ type: "quest.abandoned", questId: quest.id, quest }) + } + + for (const quest of result.expired) { + publishSystemEvent({ type: "quest.expired", questId: quest.id, quest }) + } + + return NextResponse.json( + { + ok: true, + checkedAt: result.checkedAt, + checkedQuests: result.checkedQuests, + abandoned: result.abandoned, + expired: result.expired, + }, + { headers: { "Cache-Control": "no-store" } }, + ) +} diff --git a/app/api/protocol/passport/status/route.ts b/app/api/protocol/passport/status/route.ts index 981a751..318917f 100644 --- a/app/api/protocol/passport/status/route.ts +++ b/app/api/protocol/passport/status/route.ts @@ -1,3 +1,4 @@ +import { NextResponse } from 'next/server' import { createApiRouteLogger } from '@/lib/api-logging' import { getPassport, isRegistered } from '@/lib/passport/passport' import { isMockMode } from '@/lib/mock/mock-mode' diff --git a/components/appearance-panel.tsx b/components/appearance-panel.tsx new file mode 100644 index 0000000..e18b5cb --- /dev/null +++ b/components/appearance-panel.tsx @@ -0,0 +1,435 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { toast } from "sonner" +import type { AccessoryId, AgentAppearance, MoltbotAgent, SkinId } from "@/lib/types" +import { + ACCESSORIES, + BADGES, + COLOR_CHANGE_COST_XLM, + SKINS, + getAgentLevel, + getUnlockedAccessoryIds, + getUnlockedBadgeIds, + getUnlockedSkinIds, +} from "@/lib/cosmetics" +import { drawBot } from "@/lib/renderer" +import { SPRITE_CONFIGS } from "@/components/pixel-city" + +const HEX_RE = /^#[0-9a-fA-F]{6}$/ + +interface AppearancePanelProps { + agents: MoltbotAgent[] + selectedAgent: MoltbotAgent | null + onUpdateAgentAppearance: (agentId: string, appearance: AgentAppearance) => void +} + +async function getFreighter() { + try { + return await import("@stellar/freighter-api") + } catch { + return null + } +} + +function PreviewCanvas({ agent }: { agent: MoltbotAgent }) { + const canvasRef = useRef(null) + const [sprite, setSprite] = useState(null) + const tickRef = useRef(0) + const cfg = SPRITE_CONFIGS[agent.spriteId % SPRITE_CONFIGS.length] + + useEffect(() => { + const img = new Image() + img.crossOrigin = "anonymous" + img.onload = () => setSprite(img) + img.src = cfg.path + }, [cfg.path]) + + useEffect(() => { + let raf = 0 + const render = () => { + const canvas = canvasRef.current + const ctx = canvas?.getContext("2d") + if (canvas && ctx) { + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.fillStyle = "#0a0e17" + ctx.fillRect(0, 0, canvas.width, canvas.height) + tickRef.current += 1 + const previewAgent: MoltbotAgent = { ...agent, pixelX: 50, pixelY: 22, direction: "right" } + drawBot(ctx, previewAgent, tickRef.current, false, sprite ?? undefined, cfg.crop) + } + raf = requestAnimationFrame(render) + } + raf = requestAnimationFrame(render) + return () => cancelAnimationFrame(raf) + }, [agent, sprite, cfg.crop]) + + return ( + + ) +} + +export function AppearancePanel({ agents, selectedAgent, onUpdateAgentAppearance }: AppearancePanelProps) { + const [loading, setLoading] = useState(null) + const [error, setError] = useState(null) + const [colorInput, setColorInput] = useState(selectedAgent?.color ?? "#22d3ee") + const [treasuryAddress, setTreasuryAddress] = useState(null) + + useEffect(() => { + setColorInput(selectedAgent?.color ?? "#22d3ee") + setError(null) + }, [selectedAgent?.id, selectedAgent?.color]) + + // Pull persisted equip state from the server -- local agent state resets to + // cosmetic defaults whenever the page reloads (agents are regenerated client-side). + useEffect(() => { + if (!selectedAgent) return + let cancelled = false + fetch(`/api/agents/${encodeURIComponent(selectedAgent.id)}/appearance`) + .then((res) => res.json()) + .then((data) => { + if (cancelled || !data?.ok) return + setTreasuryAddress(data.treasuryAddress ?? null) + const persisted = data.appearance as AgentAppearance + const current = selectedAgent.appearance + const same = + persisted.skin === current.skin && + persisted.customColor === current.customColor && + persisted.accessories.length === current.accessories.length && + persisted.accessories.every((a) => current.accessories.includes(a)) + if (!same) onUpdateAgentAppearance(selectedAgent.id, persisted) + }) + .catch(() => {}) + return () => { + cancelled = true + } + // Only re-sync when the selected agent changes, not on every local appearance mutation. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedAgent?.id]) + + const handleEquipSkin = useCallback( + async (skinId: SkinId, level: number, agentId: string) => { + setLoading(`skin-${skinId}`) + setError(null) + try { + const res = await fetch(`/api/agents/${encodeURIComponent(agentId)}/appearance`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "equip-skin", skinId, level }), + }) + const data = await res.json() + if (!data.ok) throw new Error(data.error || "Could not equip skin") + onUpdateAgentAppearance(agentId, data.appearance) + toast.success(`Equipped ${SKINS.find((s) => s.id === skinId)?.name} skin`) + } catch (e) { + const msg = e instanceof Error ? e.message : "Could not equip skin" + setError(msg) + toast.error("Equip failed", { description: msg }) + } + setLoading(null) + }, + [onUpdateAgentAppearance], + ) + + const handleToggleAccessory = useCallback( + async (accessoryId: AccessoryId, agent: MoltbotAgent, badgeIds: string[]) => { + const current = agent.appearance.accessories + const next = current.includes(accessoryId) ? current.filter((a) => a !== accessoryId) : [...current, accessoryId] + setLoading(`accessory-${accessoryId}`) + setError(null) + try { + const res = await fetch(`/api/agents/${encodeURIComponent(agent.id)}/appearance`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "equip-accessories", accessoryIds: next, badgeIds }), + }) + const data = await res.json() + if (!data.ok) throw new Error(data.error || "Could not update accessories") + onUpdateAgentAppearance(agent.id, data.appearance) + } catch (e) { + const msg = e instanceof Error ? e.message : "Could not update accessories" + setError(msg) + toast.error("Update failed", { description: msg }) + } + setLoading(null) + }, + [onUpdateAgentAppearance], + ) + + const handleBuyColor = useCallback( + async (agent: MoltbotAgent) => { + if (!HEX_RE.test(colorInput)) { + setError("Enter a valid 6-digit hex color (e.g. #22d3ee)") + return + } + if (!treasuryAddress) { + setError("Color purchases are disabled -- treasury address not configured") + return + } + if (!agent.wallet?.funded) { + setError("Agent needs a funded Stellar wallet to pay for color changes -- assign one in the Wallet tab") + return + } + setLoading("color") + setError(null) + try { + const buildRes = await fetch("/api/stellar/build-tx", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sourcePublic: agent.wallet.publicKey, + destination: treasuryAddress, + amount: COLOR_CHANGE_COST_XLM, + }), + }) + const buildData = await buildRes.json() + if (buildData.error) throw new Error(buildData.error) + + const freighter = await getFreighter() + if (!freighter) throw new Error("Freighter wallet not available") + + const signResult: any = await freighter.signTransaction(buildData.xdr, { + networkPassphrase: "Test SDF Network ; September 2015", + }) + const signedXdr = + typeof signResult === "string" + ? signResult + : signResult && typeof signResult === "object" && "signedTxXdr" in signResult + ? (signResult.signedTxXdr as string) + : null + if (!signedXdr) throw new Error("Freighter did not return signed XDR") + + const submitRes = await fetch("/api/stellar/submit-tx", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ signedXdr }), + }) + const submitData = await submitRes.json() + if (submitData.error) throw new Error(submitData.error) + + const appearanceRes = await fetch(`/api/agents/${encodeURIComponent(agent.id)}/appearance`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "set-color", color: colorInput, txHash: submitData.hash }), + }) + const appearanceData = await appearanceRes.json() + if (!appearanceData.ok) throw new Error(appearanceData.error || "Could not save new color") + + onUpdateAgentAppearance(agent.id, appearanceData.appearance) + toast.success(`Agent color updated for ${COLOR_CHANGE_COST_XLM} XLM`, { + description: submitData.hash ? `tx: ${submitData.hash.slice(0, 18)}…` : undefined, + }) + } catch (e) { + const msg = e instanceof Error ? e.message : "Color purchase failed" + setError(msg) + toast.error("Purchase failed", { description: msg }) + } + setLoading(null) + }, + [colorInput, treasuryAddress, onUpdateAgentAppearance], + ) + + if (!selectedAgent) { + return ( +
+
{"←"}
+ + Click an agent on the map to customize its appearance + +
+ ) + } + + const agent = selectedAgent + const level = getAgentLevel(agent) + const badgeIds = getUnlockedBadgeIds(agent, agents) + const unlockedSkins = new Set(getUnlockedSkinIds(agent)) + const unlockedAccessories = new Set(getUnlockedAccessoryIds(agent, agents)) + + return ( +
+ {/* Header + live preview */} +
+ +
+
{agent.name}
+
Level {level}
+
+ {badgeIds.length} / {BADGES.length} badges earned +
+
+
+ + {/* Skins */} +
+
+ Skins +
+
+ {SKINS.map((skin) => { + const unlocked = unlockedSkins.has(skin.id) + const equipped = agent.appearance.skin === skin.id + return ( + + ) + })} +
+
+ + {/* Accessories */} +
+
+ Accessories +
+
+ {ACCESSORIES.map((accessory) => { + const unlocked = unlockedAccessories.has(accessory.id) + const equipped = agent.appearance.accessories.includes(accessory.id) + const badge = BADGES.find((b) => b.id === accessory.badgeId) + return ( + + ) + })} +
+
+ + {/* Color customization */} +
+
+ Agent Color +
+
+ setColorInput(e.target.value)} + aria-label="Pick agent color" + style={{ width: 36, height: 36, padding: 0, border: "1px solid #1e293b", borderRadius: 4, cursor: "pointer", background: "none" }} + /> + setColorInput(e.target.value)} + placeholder="#22d3ee" + aria-label="Agent color hex value" + style={{ + flex: 1, + background: "#111827", + border: "1px solid #1e293b", + borderRadius: 4, + padding: "6px 8px", + fontFamily: "monospace", + fontSize: 11, + color: "#e2e8f0", + outline: "none", + }} + /> +
+ +
+ Paid to the protocol treasury via Freighter, Stellar Testnet +
+
+ + {error && ( +
+ {error} +
+ )} +
+ ) +} diff --git a/components/audio-controls.tsx b/components/audio-controls.tsx new file mode 100644 index 0000000..223d06f --- /dev/null +++ b/components/audio-controls.tsx @@ -0,0 +1,148 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { Volume2, VolumeX } from "lucide-react" +import { Slider } from "@/components/ui/slider" +import type { CityAudioEngine } from "@/lib/audio/city-audio" + +const VOLUME_KEY = "city-volume" +const MUTED_KEY = "city-muted" +const DEFAULT_VOLUME = 0.7 + +interface AudioControlsProps { + engine: CityAudioEngine +} + +function readStoredVolume(): number { + if (typeof window === "undefined") return DEFAULT_VOLUME + const stored = window.localStorage.getItem(VOLUME_KEY) + const parsed = stored !== null ? Number.parseFloat(stored) : NaN + return Number.isFinite(parsed) ? Math.max(0, Math.min(1, parsed)) : DEFAULT_VOLUME +} + +function readStoredMuted(): boolean { + if (typeof window === "undefined") return false + return window.localStorage.getItem(MUTED_KEY) === "true" +} + +export function AudioControls({ engine }: AudioControlsProps) { + const [volume, setVolume] = useState(DEFAULT_VOLUME) + const [muted, setMuted] = useState(false) + const [hovered, setHovered] = useState(false) + + // Apply persisted preferences to the engine once on mount. + useEffect(() => { + const initialVolume = readStoredVolume() + const initialMuted = readStoredMuted() + setVolume(initialVolume) + setMuted(initialMuted) + engine.setVolume(initialVolume) + engine.setMuted(initialMuted) + }, [engine]) + + // Unlock the AudioContext on the first user gesture anywhere on the page. + useEffect(() => { + const unlock = () => engine.init() + window.addEventListener("pointerdown", unlock, { once: true }) + window.addEventListener("keydown", unlock, { once: true }) + return () => { + window.removeEventListener("pointerdown", unlock) + window.removeEventListener("keydown", unlock) + } + }, [engine]) + + const toggleMuted = useCallback(() => { + setMuted((prev) => { + const next = !prev + engine.setMuted(next) + window.localStorage.setItem(MUTED_KEY, String(next)) + return next + }) + }, [engine]) + + // "S" toggles mute, ignored while typing in a text field. + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key.toLowerCase() !== "s") return + const target = e.target as HTMLElement | null + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return + toggleMuted() + } + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleMuted]) + + const handleVolumeChange = useCallback( + ([next]: number[]) => { + setVolume(next) + engine.setVolume(next) + window.localStorage.setItem(VOLUME_KEY, String(next)) + }, + [engine], + ) + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + onFocus={() => setHovered(true)} + onBlur={() => setHovered(false)} + style={{ + position: "absolute", + bottom: 16, + right: 16, + zIndex: 20, + display: "flex", + alignItems: "center", + gap: 8, + padding: "8px 10px", + background: "rgba(3,7,18,0.85)", + border: "1px solid #2a3a52", + borderRadius: 8, + boxShadow: "0 4px 20px rgba(0,0,0,0.4)", + }} + > + +
+ +
+
+ ) +} diff --git a/components/open-stellar/open-stellar-hub.tsx b/components/open-stellar/open-stellar-hub.tsx index 6108599..ee2980d 100644 --- a/components/open-stellar/open-stellar-hub.tsx +++ b/components/open-stellar/open-stellar-hub.tsx @@ -2,12 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" -import { PixelCity, type FloatingOverlay, type TxAnimation } from "@/components/pixel-city" +import { PixelCity, type FloatingOverlay, type ParticleTrigger, type TxAnimation } from "@/components/pixel-city" import { SidebarPanel } from "@/components/sidebar-panel" import { PriceTicker } from "@/components/price-display" +import { AudioControls } from "@/components/audio-controls" +import { CityAudioEngine } from "@/lib/audio/city-audio" import { DISTRICTS, createAgents, generateChatMessage, getRandomTask } from "@/lib/data" import type { PublishedSystemEvent } from "@/lib/events/system-events" -import type { ChatMessage, LogEntry, MoltbotAgent, WalletTransaction } from "@/lib/types" +import type { AgentAppearance, ChatMessage, LogEntry, MoltbotAgent, WalletTransaction } from "@/lib/types" function nowTime() { return new Date().toLocaleTimeString("en-US", { @@ -179,6 +181,8 @@ export function OpenStellarHub() { const [tick, setTick] = useState(0) const [txAnimations, setTxAnimations] = useState([]) const [floatingOverlays, setFloatingOverlays] = useState([]) + const [particleTriggers, setParticleTriggers] = useState([]) + const agentLevelsRef = useRef>(new Map()) const [sidebarOpen, setSidebarOpen] = useState(true) const [showOnboarding, setShowOnboarding] = useState(false) const [colorBlindMode, setColorBlindMode] = useState(false) @@ -186,6 +190,11 @@ export function OpenStellarHub() { const [eventStreamConnected, setEventStreamConnected] = useState(false) const [hasRealtimeEvents, setHasRealtimeEvents] = useState(false) const fallbackLoggedRef = useRef(false) + const [audioEngine] = useState(() => new CityAudioEngine()) + + useEffect(() => { + return () => audioEngine.dispose() + }, [audioEngine]) // Show onboarding once on first visit useEffect(() => { @@ -285,8 +294,24 @@ export function OpenStellarHub() { ]) }, []) + const spawnParticles = useCallback( + (type: ParticleTrigger["type"], x: number, y: number, opts?: ParticleTrigger["opts"]) => { + setParticleTriggers((prev) => [ + ...prev, + { + id: Date.now() + Math.floor(Math.random() * 1000), + type, + x, + y, + opts, + }, + ]) + }, + [] + ) + const applySystemEvent = useCallback((event: PublishedSystemEvent) => { - let animatedAgent: MoltbotAgent | null = null + const animatedAgentBox: { current: MoltbotAgent | null } = { current: null } setAgents((prev) => prev.map((agent) => { @@ -306,7 +331,7 @@ export function OpenStellarHub() { } if (event.type === "task.completed") { - animatedAgent = agent + animatedAgentBox.current = agent return { ...agent, status: "active", @@ -317,7 +342,7 @@ export function OpenStellarHub() { } if (event.type === "payment.received") { - animatedAgent = agent + animatedAgentBox.current = agent return { ...agent, status: "active", @@ -329,37 +354,82 @@ export function OpenStellarHub() { ) if (event.type === "task.completed") { + audioEngine.playEvent("task_complete") pushLog(`task completed: ${event.taskId} — ${event.result.summary}`, "success", event.agentId) - if (animatedAgent) { - animateAgentToDistrict(animatedAgent) - showAgentOverlay(animatedAgent, "+task", "#34d399") + const agent = animatedAgentBox.current + if (agent) { + animateAgentToDistrict(agent) + showAgentOverlay(agent, "+task", "#34d399") + const district = DISTRICTS.find((candidate) => candidate.id === agent.district) + spawnParticles("xp-burst", agent.pixelX + 8, agent.pixelY, { + color: district?.color ?? agent.color, + }) } return } if (event.type === "payment.received") { + audioEngine.playEvent("payment_received") pushLog(`payment received on ${event.receipt.chain}: ${event.receipt.txHash.slice(0, 12)}...`, "success", event.agentId) const amount = event.receipt.amountUsd ? `$${event.receipt.amountUsd.toFixed(3)}` : event.receipt.chain toast.success("Payment received", { description: `${event.agentId} settled ${amount}` }) - if (animatedAgent) { - animateAgentToDistrict(animatedAgent) - showAgentOverlay(animatedAgent, `+${amount}`, "#fbbf24") + const agent = animatedAgentBox.current + if (agent) { + animateAgentToDistrict(agent) + showAgentOverlay(agent, `+${amount}`, "#fbbf24") + const xlmAmount = event.receipt.amountUnits ? `+${event.receipt.amountUnits} XLM` : "+0.01 XLM" + spawnParticles("payment-spark", agent.pixelX + 8, agent.pixelY + 10, { + amount: xlmAmount, + }) } return } if (event.type === "agent.xp") { + audioEngine.playEvent("level_up") pushLog(`XP update: +${event.xp}, level ${event.level}`, "success", event.agentId) const agent = agentsRef.current.find((candidate) => candidate.id === event.agentId) - if (agent) showAgentOverlay(agent, `+${event.xp} XP`, "#22d3ee") + if (agent) { + showAgentOverlay(agent, `+${event.xp} XP`, "#22d3ee") + const previousLevel = agentLevelsRef.current.get(event.agentId) ?? event.level + if (event.level > previousLevel) { + spawnParticles("level-up", agent.pixelX + 8, agent.pixelY, { + color: agent.color, + level: event.level, + }) + } + agentLevelsRef.current.set(event.agentId, event.level) + } return } if (event.type === "badge.unlocked") { + audioEngine.playEvent("badge_unlock") pushLog(`badge unlocked: ${event.badge.name}`, "success", event.agentId) toast.success("Badge unlocked", { description: `${event.agentId}: ${event.badge.name}` }) const agent = agentsRef.current.find((candidate) => candidate.id === event.agentId) - if (agent) showAgentOverlay(agent, event.badge.name, "#a78bfa") + if (agent) { + showAgentOverlay(agent, event.badge.name, "#a78bfa") + spawnParticles("badge-unlock", agent.pixelX + 8, agent.pixelY, { + rarity: event.badge.rarity ?? "common", + }) + } + return + } + + if (event.type === "district.unlocked") { + audioEngine.playEvent("district_win") + const districtId = event.districtId ?? event.district?.id + const district = DISTRICTS.find((candidate) => candidate.id === districtId) + const districtName = event.district?.name ?? district?.name ?? districtId ?? "a district" + pushLog(`district unlocked: ${districtName}`, "success", event.agentId ?? "system") + toast.success("District unlocked", { description: String(districtName) }) + if (district) { + spawnParticles("district-win", district.x + district.w / 2, district.y, { + color: district.color, + spreadW: district.w * 0.7, + }) + } return } @@ -368,8 +438,12 @@ export function OpenStellarHub() { return } - pushLog(`status changed: ${event.status}`, "info", event.agentId) - }, [animateAgentToDistrict, pushLog, showAgentOverlay]) + if (event.type === "agent.status") { + if (event.status === "error") audioEngine.playEvent("agent_error") + pushLog(`status changed: ${event.status}`, "info", event.agentId) + return + } + }, [animateAgentToDistrict, audioEngine, pushLog, showAgentOverlay, spawnParticles]) useEffect(() => { const eventSource = new EventSource("/api/events") @@ -380,6 +454,7 @@ export function OpenStellarHub() { "payment.received", "agent.xp", "badge.unlocked", + "district.unlocked", ] const handleEvent = (message: MessageEvent) => { @@ -581,6 +656,16 @@ export function OpenStellarHub() { return () => window.clearInterval(id) }, [floatingOverlays.length]) + // Particle triggers are one-shot — PixelCity consumes them into its ParticleSystem on + // receipt, so this just garbage-collects the request objects shortly after. + useEffect(() => { + if (particleTriggers.length === 0) return + const id = window.setTimeout(() => { + setParticleTriggers([]) + }, 500) + return () => window.clearTimeout(id) + }, [particleTriggers]) + const handleSelectAgent = useCallback((id: string | null) => { setSelectedAgentId(id) @@ -601,6 +686,16 @@ export function OpenStellarHub() { }) }, [pushLog]) + const handleUpdateAgentAppearance = useCallback((agentId: string, appearance: AgentAppearance) => { + setAgents((prev) => + prev.map((agent) => + agent.id === agentId + ? { ...agent, appearance, color: appearance.customColor || agent.color } + : agent, + ), + ) + }, []) + const handleAddTransaction = useCallback((tx: WalletTransaction) => { setTransactions((prev) => [tx, ...prev.slice(0, 99)]) pushLog(`tx ${tx.fromName} -> ${tx.toName} (${tx.amount} XLM)`, "success", tx.fromName) @@ -641,8 +736,12 @@ export function OpenStellarHub() { colorBlindMode={colorBlindMode} reduceMotion={reduceMotion} floatingOverlays={floatingOverlays} + particleTriggers={particleTriggers} + audioEngine={audioEngine} /> + + {/* Sidebar toggle button */}