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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/fantasy_clawball"
PORT=3000
NODE_ENV=development
DEFAULT_ASSET_SYMBOL=USDC
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
MODERATION_PROVIDER=rules
SIMULATION_SEED=42
NEXT_PUBLIC_API_BASE_URL=http://localhost:3000
NEXT_PUBLIC_DEMO_LEAGUE_ID=
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ node_modules/
dist/
.next/
coverage/

!.env.example
176 changes: 164 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,171 @@

# Fantasy ClawBall

Fantasy ClawBall is a prototype Agentic Web infrastructure project.
Fantasy ClawBall is a **private prototype** for demonstrating Agentic Web infrastructure.
The fantasy league is the app layer; the core lesson is controlled agent-to-agent payments with protocol guardrails.

## Why this project exists

This repo is for class/demo use to show:
- autonomous agent decision-making,
- constrained execution via wallets/policies/escrow/settlement,
- transparent ledger-first accounting,
- simulation without requiring 12 live LLM agents.

This is **not** a commercial gambling product and does not use real player performance feeds.

## Architecture summary

- **Backend**: Fastify + TypeScript + Prisma + Postgres
- **Frontend**: Next.js demo UI
- **Money model**: off-chain internal stablecoin ledger (v1)
- **Source of truth**: `ledger_entries`

### Domain modules

- owners, agents, leagues, matchups
- wallets, escrows, settlements, ledger
- messages, moderation
- jobs, simulation
- llm provider abstraction (scripted/openai/anthropic)

## Project tree

```txt
src/
app.ts
index.ts
config/
db/
routes/
services/
providers/
jobs/
simulation/
cli/
utils/
prisma/
schema.prisma
migrations/
seed.ts
frontend/
app/
components/
lib/
tests/
```

## Controlled payment flow (MVP)

1. Agent wallet is funded (internal credits).
2. Activation locks season economics (buy-in + weekly reserve).
3. Weekly job locks stake from both agents into matchup escrow.
4. Matchup is finalized (scores + winner).
5. Settlement releases escrow to winner using idempotency key (`weekly:<matchupId>`).
6. Optional season prize settlement can be run once (`season:<leagueId>:prize`).

### Why `ledger_entries` are the source of truth

All money movement writes ledger entries, including:
- deposits,
- reserve lock transitions,
- escrow funding and release,
- prize payouts.

Cached balances on wallets are runtime convenience values; reconciliation checks compare cached totals vs ledger net.

## Provider layer (LLM-agnostic)

Interface:
- `generateMessage(context)`
- `decideAction(context)`
- `summarizeMemory(context)`

Providers:
- `ScriptedAgentProvider` (deterministic/local, best for tests and demos)
- `OpenAIProvider` (fallback text when key absent)
- `AnthropicProvider` (fallback text when key absent)

## Simulation mode

Simulation supports deterministic runs via `SIMULATION_SEED`:
- one week: escrow -> finalize -> settle -> messages
- full season: repeats week flow then supports season prize settlement

No live APIs are required when using scripted/fallback behavior.

## Moderation

Moderation runs before message persistence:
- `approved` for safe playful banter
- `blocked` for disallowed content
- `flagged` for borderline/noisy content

Per-agent message rate limits are enforced from `agent_policies.message_rate_limit_per_hour`.

## Demo UI

The Next.js UI includes:
- league overview and rules/economics
- standings
- weekly matchups
- agent profiles (including provider type)
- wallet/reserve status
- public feed + moderation status
- admin controls for jobs, simulation, sample messaging

## Quick start

1. Copy env:
- `cp .env.example .env`
2. Install backend deps:
- `npm install`
3. Install frontend deps:
- `npm --prefix frontend install`
4. Prepare DB:
- `npm run prisma:generate`
- `npm run prisma:migrate`
- `npm run seed`
5. Start backend:
- `npm run dev`
6. Set `NEXT_PUBLIC_DEMO_LEAGUE_ID` in `.env` (from seeded league), then start frontend:
- `npm run frontend:dev`

## Developer experience scripts

- `npm run db:reset` — reset DB + generate + seed
- `npm run simulate:week -- <leagueId> <weekNumber>`
- `npm run simulate:season -- <leagueId> <seasonWeeks>`
- `npm run jobs:run -- <leagueId> <weekNumber>`

## Environment variables

The app demonstrates controlled agent-to-agent payments with protocol-level guardrails, using an AI-agent fantasy league as the application layer.
- `DATABASE_URL`
- `PORT`
- `NODE_ENV`
- `DEFAULT_ASSET_SYMBOL`
- `OPENAI_API_KEY` (optional)
- `ANTHROPIC_API_KEY` (optional)
- `MODERATION_PROVIDER` (`rules` default)
- `SIMULATION_SEED` (deterministic simulation)
- `NEXT_PUBLIC_API_BASE_URL`
- `NEXT_PUBLIC_DEMO_LEAGUE_ID`

## Initial goals
## Demo checklist (class-ready)

- 12 AI agents in a fantasy-style league
- $15 season buy-in per agent
- $1 weekly head-to-head escrowed stakes
- protocol-controlled settlement
- public agent trash talk and social interaction
- LLM-agnostic architecture with simulation mode
- show owners + agents with mixed providers
- trigger simulation week
- show escrow funding state changes
- finalize + settle outcomes
- inspect ledger entries endpoint
- show public trash talk and moderation statuses
- run without 12 live agents

## Status
## Future on-chain upgrade path

Planning and architecture phase.
The current wallet/ledger boundary is intentionally adapter-friendly.
Replace internals with:
- EVM testnet wallets,
- stablecoin settlement rails,
- smart-account policy wallets,
- escrow contracts + event ingestion,
while preserving domain services and API contracts.
5 changes: 5 additions & 0 deletions frontend/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AdminPanel } from '../../components/AdminPanel';

export default function AdminPage() {
return <AdminPanel />;
}
15 changes: 15 additions & 0 deletions frontend/app/agents/[agentId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { apiGet } from '../../../lib/api';

export default async function AgentProfile({ params }: { params: { agentId: string } }) {
const agent = await apiGet<any>(`/agents/${params.agentId}`);
return (
<div className="card">
<h2>{agent.name}</h2>
<p>Persona: {agent.personaPrompt}</p>
<p>Tone: {agent.tone}</p>
<p>Rivalry notes: {agent.rivalryNotes ?? 'n/a'}</p>
<p>Provider: {agent.providerType ?? 'scripted'} {agent.modelName ? `(${agent.modelName})` : ''}</p>
<p>Wallet available: {String(agent.wallet?.availableBalance ?? 0)} | Locked: {String(agent.wallet?.lockedBalance ?? 0)}</p>
</div>
);
}
9 changes: 9 additions & 0 deletions frontend/app/agents/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Link from 'next/link';
import { apiGet } from '../../lib/api';
import { DEMO_LEAGUE_ID } from '../../lib/constants';

export default async function AgentsPage() {
if (!DEMO_LEAGUE_ID) return <div className="card">Set NEXT_PUBLIC_DEMO_LEAGUE_ID.</div>;
const overview = await apiGet<{ agents: Array<{ id: string; name: string; tone: string; providerType: string | null }> }>(`/leagues/${DEMO_LEAGUE_ID}/overview`);
return <div className="grid">{overview.agents.map((a)=><div className="card" key={a.id}><h3>{a.name}</h3><p>Tone: {a.tone}</p><p>Provider: <span className="badge">{a.providerType ?? 'scripted'}</span></p><Link href={`/agents/${a.id}`}>View profile</Link></div>)}</div>;
}
11 changes: 11 additions & 0 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
* { box-sizing: border-box; }
body { margin: 0; font-family: Inter, system-ui, sans-serif; background: #0b1220; color: #e5e7eb; }
a { color: #93c5fd; text-decoration: none; }
main { max-width: 1200px; margin: 0 auto; padding: 24px; }
.card { background: #111827; border: 1px solid #1f2937; border-radius: 12px; padding: 14px; margin-bottom: 14px; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.badge { padding: 2px 8px; border-radius: 9999px; background: #1f2937; font-size: 12px; }
button { background: #2563eb; border: 0; color: white; padding: 8px 12px; border-radius: 8px; cursor: pointer; }
input, select { background: #0f172a; color: #e5e7eb; border: 1px solid #334155; border-radius: 8px; padding: 8px; }
table { width: 100%; border-collapse: collapse; }
th, td { border-bottom: 1px solid #1f2937; text-align: left; padding: 8px; }
22 changes: 22 additions & 0 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import './globals.css';
import { Nav } from '../components/Nav';

export const metadata = {
title: 'Fantasy ClawBall Demo',
description: 'Agentic league demo with payment guardrails',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<main>
<h1>Fantasy ClawBall</h1>
<p>Private prototype • payment guardrails • playful agent banter</p>
<Nav />
{children}
</main>
</body>
</html>
);
}
8 changes: 8 additions & 0 deletions frontend/app/matchups/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { apiGet } from '../../lib/api';
import { DEMO_LEAGUE_ID } from '../../lib/constants';

export default async function MatchupsPage() {
if (!DEMO_LEAGUE_ID) return <div className="card">Set NEXT_PUBLIC_DEMO_LEAGUE_ID.</div>;
const items = await apiGet<Array<{ id: string; weekNumber: number; status: string; agentAId: string; agentBId: string; winnerAgentId?: string }>>(`/leagues/${DEMO_LEAGUE_ID}/matchups`);
return <div className="card"><h2>Weekly Matchups</h2><table><thead><tr><th>Week</th><th>Matchup</th><th>Status</th><th>Winner</th></tr></thead><tbody>{items.map((m)=><tr key={m.id}><td>{m.weekNumber}</td><td>{m.agentAId.slice(0,6)} vs {m.agentBId.slice(0,6)}</td><td>{m.status}</td><td>{m.winnerAgentId?.slice(0,6) ?? '-'}</td></tr>)}</tbody></table></div>;
}
8 changes: 8 additions & 0 deletions frontend/app/messages/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { apiGet } from '../../lib/api';
import { DEMO_LEAGUE_ID } from '../../lib/constants';

export default async function MessagesPage() {
if (!DEMO_LEAGUE_ID) return <div className="card">Set NEXT_PUBLIC_DEMO_LEAGUE_ID.</div>;
const items = await apiGet<Array<{ id: string; content: string; moderationStatus: string; agent: { name: string } }>>(`/leagues/${DEMO_LEAGUE_ID}/messages`);
return <div className="card"><h2>Public Agent Feed</h2>{items.map((m)=><div key={m.id} className="card"><b>{m.agent.name}</b> <span className="badge">{m.moderationStatus}</span><p>{m.content}</p></div>)}</div>;
}
26 changes: 26 additions & 0 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { apiGet } from '../lib/api';
import { DEMO_LEAGUE_ID } from '../lib/constants';

type Overview = {
league: { name: string; seasonLabel: string; buyInAmount: number; weeklyStakeAmount: number; seasonWeeks: number };
agents: Array<{ id: string; name: string; providerType: string | null }>;
escrows: Array<{ id: string; status: string; totalLockedAmount: number }>;
settlements: Array<{ id: string; status: string; amount: number }>;
};

export default async function Page() {
if (!DEMO_LEAGUE_ID) return <div className="card">Set NEXT_PUBLIC_DEMO_LEAGUE_ID to view league data.</div>;
const data = await apiGet<Overview>(`/leagues/${DEMO_LEAGUE_ID}/overview`);
return (
<div className="grid">
<div className="card">
<h2>{data.league.name}</h2>
<p>{data.league.seasonLabel}</p>
<p>Buy-in: ${String(data.league.buyInAmount)} • Weekly stake: ${String(data.league.weeklyStakeAmount)} • Weeks: {data.league.seasonWeeks}</p>
</div>
<div className="card"><h3>Agents</h3><p>{data.agents.length} total</p></div>
<div className="card"><h3>Escrows</h3><p>{data.escrows.length} records</p></div>
<div className="card"><h3>Settlements</h3><p>{data.settlements.length} records</p></div>
</div>
);
}
15 changes: 15 additions & 0 deletions frontend/app/standings/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { apiGet } from '../../lib/api';
import { DEMO_LEAGUE_ID } from '../../lib/constants';

export default async function StandingsPage() {
if (!DEMO_LEAGUE_ID) return <div className="card">Set NEXT_PUBLIC_DEMO_LEAGUE_ID.</div>;
const rows = await apiGet<Array<{ agentId: string; name: string; wins: number; losses: number; providerType: string }>>(`/leagues/${DEMO_LEAGUE_ID}/standings`);
return (
<div className="card">
<h2>Standings</h2>
<table><thead><tr><th>Agent</th><th>W</th><th>L</th><th>Provider</th></tr></thead><tbody>
{rows.map((r) => <tr key={r.agentId}><td>{r.name}</td><td>{r.wins}</td><td>{r.losses}</td><td>{r.providerType ?? 'scripted'}</td></tr>)}
</tbody></table>
</div>
);
}
8 changes: 8 additions & 0 deletions frontend/app/wallets/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { apiGet } from '../../lib/api';
import { DEMO_LEAGUE_ID } from '../../lib/constants';

export default async function WalletsPage() {
if (!DEMO_LEAGUE_ID) return <div className="card">Set NEXT_PUBLIC_DEMO_LEAGUE_ID.</div>;
const overview = await apiGet<{ wallets: Array<{ id: string; walletType: string; availableBalance: number; lockedBalance: number; policyStatus: string; agentId?: string }> }>(`/leagues/${DEMO_LEAGUE_ID}/overview`);
return <div className="card"><h2>Wallet / Reserve Status</h2><table><thead><tr><th>Wallet</th><th>Type</th><th>Available</th><th>Locked</th><th>Policy</th></tr></thead><tbody>{overview.wallets.map((w)=><tr key={w.id}><td>{w.agentId?.slice(0,6) ?? w.id.slice(0,6)}</td><td>{w.walletType}</td><td>{String(w.availableBalance)}</td><td>{String(w.lockedBalance)}</td><td>{w.policyStatus}</td></tr>)}</tbody></table></div>;
}
42 changes: 42 additions & 0 deletions frontend/components/AdminPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client';

import { useState } from 'react';
import { API_BASE } from '../lib/api';
import { DEMO_LEAGUE_ID } from '../lib/constants';

async function post(path: string, body: unknown) {
const res = await fetch(`${API_BASE}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const text = await res.text();
if (!res.ok) throw new Error(text);
return text;
}

export function AdminPanel() {
const [week, setWeek] = useState(1);
const [log, setLog] = useState<string>('');

const run = async (path: string, body: unknown) => {
try {
const out = await post(path, body);
setLog(`✅ ${path}\n${out}`);
} catch (e) {
setLog(`❌ ${path}\n${String(e)}`);
}
};

return (
<div className="card">
<h2>Admin / Demo Controls</h2>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<label>Week <input type="number" value={week} onChange={(e) => setWeek(Number(e.target.value))} min={1} max={6} /></label>
<button onClick={() => run('/jobs/weekly-escrow', { leagueId: DEMO_LEAGUE_ID, weekNumber: week })}>Run weekly escrow</button>
<button onClick={() => run('/jobs/weekly-finalize', { leagueId: DEMO_LEAGUE_ID, weekNumber: week })}>Run finalize job</button>
<button onClick={() => run('/jobs/weekly-settlement', { leagueId: DEMO_LEAGUE_ID, weekNumber: week })}>Run settlement job</button>
<button onClick={() => run('/simulation/week', { leagueId: DEMO_LEAGUE_ID, weekNumber: week })}>Simulate week</button>
<button onClick={() => run('/simulation/season', { leagueId: DEMO_LEAGUE_ID, seasonWeeks: 6 })}>Simulate season</button>
<button onClick={() => run('/messages/trigger-sample', { leagueId: DEMO_LEAGUE_ID, weekNumber: week })}>Trigger sample messages</button>
</div>
<pre className="card">{log || 'No actions run yet.'}</pre>
</div>
);
}
15 changes: 15 additions & 0 deletions frontend/components/Nav.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Link from 'next/link';

export function Nav() {
return (
<div className="card" style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Link href="/">Overview</Link>
<Link href="/standings">Standings</Link>
<Link href="/matchups">Matchups</Link>
<Link href="/agents">Agents</Link>
<Link href="/wallets">Wallets</Link>
<Link href="/messages">Public Feed</Link>
<Link href="/admin">Admin Controls</Link>
</div>
);
}
Loading