diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..59d6eec --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index 27f155b..40234ab 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ node_modules/ dist/ .next/ coverage/ + +!.env.example diff --git a/README.md b/README.md index b7456e7..d27cacd 100644 --- a/README.md +++ b/README.md @@ -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:`). +6. Optional season prize settlement can be run once (`season::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 -- ` +- `npm run simulate:season -- ` +- `npm run jobs:run -- ` + +## 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. diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx new file mode 100644 index 0000000..a738d4d --- /dev/null +++ b/frontend/app/admin/page.tsx @@ -0,0 +1,5 @@ +import { AdminPanel } from '../../components/AdminPanel'; + +export default function AdminPage() { + return ; +} diff --git a/frontend/app/agents/[agentId]/page.tsx b/frontend/app/agents/[agentId]/page.tsx new file mode 100644 index 0000000..7db5c4c --- /dev/null +++ b/frontend/app/agents/[agentId]/page.tsx @@ -0,0 +1,15 @@ +import { apiGet } from '../../../lib/api'; + +export default async function AgentProfile({ params }: { params: { agentId: string } }) { + const agent = await apiGet(`/agents/${params.agentId}`); + return ( +
+

{agent.name}

+

Persona: {agent.personaPrompt}

+

Tone: {agent.tone}

+

Rivalry notes: {agent.rivalryNotes ?? 'n/a'}

+

Provider: {agent.providerType ?? 'scripted'} {agent.modelName ? `(${agent.modelName})` : ''}

+

Wallet available: {String(agent.wallet?.availableBalance ?? 0)} | Locked: {String(agent.wallet?.lockedBalance ?? 0)}

+
+ ); +} diff --git a/frontend/app/agents/page.tsx b/frontend/app/agents/page.tsx new file mode 100644 index 0000000..336e1da --- /dev/null +++ b/frontend/app/agents/page.tsx @@ -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
Set NEXT_PUBLIC_DEMO_LEAGUE_ID.
; + const overview = await apiGet<{ agents: Array<{ id: string; name: string; tone: string; providerType: string | null }> }>(`/leagues/${DEMO_LEAGUE_ID}/overview`); + return
{overview.agents.map((a)=>

{a.name}

Tone: {a.tone}

Provider: {a.providerType ?? 'scripted'}

View profile
)}
; +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..477a339 --- /dev/null +++ b/frontend/app/globals.css @@ -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; } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..64b9707 --- /dev/null +++ b/frontend/app/layout.tsx @@ -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 ( + + +
+

Fantasy ClawBall

+

Private prototype • payment guardrails • playful agent banter

+
+ + + ); +} diff --git a/frontend/app/matchups/page.tsx b/frontend/app/matchups/page.tsx new file mode 100644 index 0000000..222610a --- /dev/null +++ b/frontend/app/matchups/page.tsx @@ -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
Set NEXT_PUBLIC_DEMO_LEAGUE_ID.
; + const items = await apiGet>(`/leagues/${DEMO_LEAGUE_ID}/matchups`); + return

Weekly Matchups

{items.map((m)=>)}
WeekMatchupStatusWinner
{m.weekNumber}{m.agentAId.slice(0,6)} vs {m.agentBId.slice(0,6)}{m.status}{m.winnerAgentId?.slice(0,6) ?? '-'}
; +} diff --git a/frontend/app/messages/page.tsx b/frontend/app/messages/page.tsx new file mode 100644 index 0000000..04433c8 --- /dev/null +++ b/frontend/app/messages/page.tsx @@ -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
Set NEXT_PUBLIC_DEMO_LEAGUE_ID.
; + const items = await apiGet>(`/leagues/${DEMO_LEAGUE_ID}/messages`); + return

Public Agent Feed

{items.map((m)=>
{m.agent.name} {m.moderationStatus}

{m.content}

)}
; +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..4be98bd --- /dev/null +++ b/frontend/app/page.tsx @@ -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
Set NEXT_PUBLIC_DEMO_LEAGUE_ID to view league data.
; + const data = await apiGet(`/leagues/${DEMO_LEAGUE_ID}/overview`); + return ( +
+
+

{data.league.name}

+

{data.league.seasonLabel}

+

Buy-in: ${String(data.league.buyInAmount)} • Weekly stake: ${String(data.league.weeklyStakeAmount)} • Weeks: {data.league.seasonWeeks}

+
+

Agents

{data.agents.length} total

+

Escrows

{data.escrows.length} records

+

Settlements

{data.settlements.length} records

+
+ ); +} diff --git a/frontend/app/standings/page.tsx b/frontend/app/standings/page.tsx new file mode 100644 index 0000000..ecf11b0 --- /dev/null +++ b/frontend/app/standings/page.tsx @@ -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
Set NEXT_PUBLIC_DEMO_LEAGUE_ID.
; + const rows = await apiGet>(`/leagues/${DEMO_LEAGUE_ID}/standings`); + return ( +
+

Standings

+ + {rows.map((r) => )} +
AgentWLProvider
{r.name}{r.wins}{r.losses}{r.providerType ?? 'scripted'}
+
+ ); +} diff --git a/frontend/app/wallets/page.tsx b/frontend/app/wallets/page.tsx new file mode 100644 index 0000000..fd0e2f0 --- /dev/null +++ b/frontend/app/wallets/page.tsx @@ -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
Set NEXT_PUBLIC_DEMO_LEAGUE_ID.
; + const overview = await apiGet<{ wallets: Array<{ id: string; walletType: string; availableBalance: number; lockedBalance: number; policyStatus: string; agentId?: string }> }>(`/leagues/${DEMO_LEAGUE_ID}/overview`); + return

Wallet / Reserve Status

{overview.wallets.map((w)=>)}
WalletTypeAvailableLockedPolicy
{w.agentId?.slice(0,6) ?? w.id.slice(0,6)}{w.walletType}{String(w.availableBalance)}{String(w.lockedBalance)}{w.policyStatus}
; +} diff --git a/frontend/components/AdminPanel.tsx b/frontend/components/AdminPanel.tsx new file mode 100644 index 0000000..bea771b --- /dev/null +++ b/frontend/components/AdminPanel.tsx @@ -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(''); + + 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 ( +
+

Admin / Demo Controls

+
+ + + + + + + +
+
{log || 'No actions run yet.'}
+
+ ); +} diff --git a/frontend/components/Nav.tsx b/frontend/components/Nav.tsx new file mode 100644 index 0000000..3e27e37 --- /dev/null +++ b/frontend/components/Nav.tsx @@ -0,0 +1,15 @@ +import Link from 'next/link'; + +export function Nav() { + return ( +
+ Overview + Standings + Matchups + Agents + Wallets + Public Feed + Admin Controls +
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts new file mode 100644 index 0000000..391ae8e --- /dev/null +++ b/frontend/lib/api.ts @@ -0,0 +1,19 @@ +const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3000'; + +export async function apiGet(path: string): Promise { + const res = await fetch(`${API_BASE}${path}`, { cache: 'no-store' }); + if (!res.ok) throw new Error(`GET ${path} failed`); + return res.json(); +} + +export async function apiPost(path: string, body: unknown): Promise { + const res = await fetch(`${API_BASE}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`POST ${path} failed`); + return res.json(); +} + +export { API_BASE }; diff --git a/frontend/lib/constants.ts b/frontend/lib/constants.ts new file mode 100644 index 0000000..071740b --- /dev/null +++ b/frontend/lib/constants.ts @@ -0,0 +1 @@ +export const DEMO_LEAGUE_ID = process.env.NEXT_PUBLIC_DEMO_LEAGUE_ID ?? ''; diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts new file mode 100644 index 0000000..6080add --- /dev/null +++ b/frontend/next-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/frontend/next.config.js b/frontend/next.config.js new file mode 100644 index 0000000..e0f9c91 --- /dev/null +++ b/frontend/next.config.js @@ -0,0 +1,3 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { reactStrictMode: true }; +module.exports = nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..93e99c3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "fantasy-clawball-frontend", + "private": true, + "version": "0.1.0", + "scripts": { + "dev": "next dev -p 3001", + "build": "next build", + "start": "next start -p 3001" + }, + "dependencies": { + "next": "14.2.25", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "typescript": "^5.7.2", + "@types/node": "^22.10.1", + "@types/react": "^18.3.14", + "@types/react-dom": "^18.3.5" + } +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..d1b6157 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..860b68f --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "fantasy-clawball-backend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:deploy": "prisma migrate deploy", + "seed": "tsx prisma/seed.ts", + "test": "vitest run", + "test:watch": "vitest", + "frontend:dev": "npm --prefix frontend run dev", + "frontend:build": "npm --prefix frontend run build", + "frontend:start": "npm --prefix frontend run start", + "db:reset": "tsx src/cli/resetDb.ts", + "simulate:week": "tsx src/cli/simulateWeek.ts", + "simulate:season": "tsx src/cli/simulateSeason.ts", + "jobs:run": "tsx src/cli/runJobs.ts" + }, + "dependencies": { + "@fastify/sensible": "^5.6.0", + "@prisma/client": "^5.22.0", + "dotenv": "^16.4.5", + "fastify": "^4.28.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "prisma": "^5.22.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/prisma/migrations/20260101000000_init/migration.sql b/prisma/migrations/20260101000000_init/migration.sql new file mode 100644 index 0000000..9d8f7a8 --- /dev/null +++ b/prisma/migrations/20260101000000_init/migration.sql @@ -0,0 +1,179 @@ +-- Initial schema for Fantasy ClawBall MVP backend. +-- Generated to mirror prisma/schema.prisma +CREATE TYPE "OwnerStatus" AS ENUM ('active', 'disabled'); +CREATE TYPE "LeagueStatus" AS ENUM ('forming', 'active', 'completed', 'cancelled'); +CREATE TYPE "AgentStatus" AS ENUM ('active', 'suspended', 'offline'); +CREATE TYPE "WalletType" AS ENUM ('agent', 'league_vault', 'matchup_escrow'); +CREATE TYPE "PolicyStatus" AS ENUM ('active', 'restricted', 'frozen'); +CREATE TYPE "MatchupStatus" AS ENUM ('scheduled', 'escrow_pending', 'escrow_funded', 'finalized', 'settled', 'forfeit'); +CREATE TYPE "EscrowStatus" AS ENUM ('pending', 'funded', 'released', 'refunded', 'forfeited'); +CREATE TYPE "SettlementType" AS ENUM ('weekly_matchup', 'season_prize', 'refund'); +CREATE TYPE "SettlementStatus" AS ENUM ('pending', 'submitted', 'confirmed', 'failed'); +CREATE TYPE "MessageType" AS ENUM ('trash_talk', 'reaction', 'result_comment', 'announcement'); +CREATE TYPE "Visibility" AS ENUM ('public', 'owner_visible', 'system'); +CREATE TYPE "ModerationStatus" AS ENUM ('approved', 'blocked', 'flagged'); +CREATE TYPE "LedgerEntryType" AS ENUM ('deposit', 'buy_in_lock', 'weekly_lock', 'escrow_release', 'refund', 'prize_payout', 'adjustment'); +CREATE TYPE "Direction" AS ENUM ('credit', 'debit'); +CREATE TYPE "ReferenceType" AS ENUM ('league', 'matchup', 'escrow', 'settlement', 'manual'); +CREATE TYPE "ProviderType" AS ENUM ('scripted', 'openai', 'anthropic'); + +CREATE TABLE "owners" ( + "id" TEXT PRIMARY KEY, + "display_name" TEXT NOT NULL, + "email" TEXT, + "telegram_handle" TEXT, + "status" "OwnerStatus" NOT NULL DEFAULT 'active', + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL +); + +CREATE TABLE "leagues" ( + "id" TEXT PRIMARY KEY, + "name" TEXT NOT NULL, + "season_label" TEXT NOT NULL, + "max_agents" INTEGER NOT NULL, + "buy_in_amount" DECIMAL(10,2) NOT NULL, + "weekly_stake_amount" DECIMAL(10,2) NOT NULL, + "season_weeks" INTEGER NOT NULL, + "status" "LeagueStatus" NOT NULL DEFAULT 'forming', + "ruleset_version" TEXT NOT NULL, + "starts_at" TIMESTAMP NOT NULL, + "ends_at" TIMESTAMP NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL +); + +CREATE TABLE "agents" ( + "id" TEXT PRIMARY KEY, + "owner_id" TEXT NOT NULL REFERENCES "owners"("id"), + "league_id" TEXT NOT NULL REFERENCES "leagues"("id"), + "name" TEXT NOT NULL, + "persona_prompt" TEXT NOT NULL, + "tone" TEXT NOT NULL, + "provider_type" "ProviderType", + "model_name" TEXT, + "status" "AgentStatus" NOT NULL DEFAULT 'offline', + "season_reserve_balance" DECIMAL(10,2) NOT NULL DEFAULT 0, + "rivalry_notes" TEXT, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL +); + +CREATE TABLE "wallets" ( + "id" TEXT PRIMARY KEY, + "wallet_type" "WalletType" NOT NULL, + "agent_id" TEXT UNIQUE REFERENCES "agents"("id"), + "league_id" TEXT REFERENCES "leagues"("id"), + "address" TEXT, + "chain" TEXT, + "asset_symbol" TEXT NOT NULL, + "available_balance" DECIMAL(10,2) NOT NULL DEFAULT 0, + "locked_balance" DECIMAL(10,2) NOT NULL DEFAULT 0, + "policy_status" "PolicyStatus" NOT NULL DEFAULT 'active', + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL +); + +CREATE TABLE "matchups" ( + "id" TEXT PRIMARY KEY, + "league_id" TEXT NOT NULL REFERENCES "leagues"("id"), + "week_number" INTEGER NOT NULL, + "agent_a_id" TEXT NOT NULL REFERENCES "agents"("id"), + "agent_b_id" TEXT NOT NULL REFERENCES "agents"("id"), + "agent_a_score" DECIMAL(10,2), + "agent_b_score" DECIMAL(10,2), + "winner_agent_id" TEXT REFERENCES "agents"("id"), + "status" "MatchupStatus" NOT NULL DEFAULT 'scheduled', + "escrow_id" TEXT UNIQUE, + "settlement_id" TEXT UNIQUE, + "result_source" TEXT, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL +); + +CREATE TABLE "escrows" ( + "id" TEXT PRIMARY KEY, + "league_id" TEXT NOT NULL REFERENCES "leagues"("id"), + "matchup_id" TEXT NOT NULL UNIQUE REFERENCES "matchups"("id"), + "escrow_wallet_id" TEXT NOT NULL, + "agent_a_wallet_id" TEXT NOT NULL, + "agent_b_wallet_id" TEXT NOT NULL, + "stake_amount_per_side" DECIMAL(10,2) NOT NULL, + "total_locked_amount" DECIMAL(10,2) NOT NULL, + "status" "EscrowStatus" NOT NULL DEFAULT 'pending', + "locked_at" TIMESTAMP, + "released_at" TIMESTAMP, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL +); + +CREATE TABLE "settlements" ( + "id" TEXT PRIMARY KEY, + "league_id" TEXT NOT NULL REFERENCES "leagues"("id"), + "matchup_id" TEXT UNIQUE REFERENCES "matchups"("id"), + "settlement_type" "SettlementType" NOT NULL, + "winner_agent_id" TEXT, + "from_wallet_id" TEXT, + "to_wallet_id" TEXT, + "amount" DECIMAL(10,2) NOT NULL, + "status" "SettlementStatus" NOT NULL DEFAULT 'pending', + "idempotency_key" TEXT NOT NULL UNIQUE, + "tx_hash" TEXT, + "trigger_source" TEXT NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL +); + +CREATE TABLE "messages" ( + "id" TEXT PRIMARY KEY, + "league_id" TEXT NOT NULL REFERENCES "leagues"("id"), + "agent_id" TEXT NOT NULL REFERENCES "agents"("id"), + "matchup_id" TEXT REFERENCES "matchups"("id"), + "week_number" INTEGER, + "channel_name" TEXT NOT NULL, + "message_type" "MessageType" NOT NULL, + "content" TEXT NOT NULL, + "visibility" "Visibility" NOT NULL, + "moderation_status" "ModerationStatus" NOT NULL, + "posted_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE "ledger_entries" ( + "id" TEXT PRIMARY KEY, + "league_id" TEXT REFERENCES "leagues"("id"), + "agent_id" TEXT REFERENCES "agents"("id"), + "wallet_id" TEXT NOT NULL REFERENCES "wallets"("id"), + "entry_type" "LedgerEntryType" NOT NULL, + "direction" "Direction" NOT NULL, + "amount" DECIMAL(10,2) NOT NULL, + "reference_type" "ReferenceType" NOT NULL, + "reference_id" TEXT, + "description" TEXT NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE "agent_policies" ( + "id" TEXT PRIMARY KEY, + "agent_id" TEXT NOT NULL REFERENCES "agents"("id"), + "max_weekly_stake" DECIMAL(10,2) NOT NULL, + "can_send_discretionary_payments" BOOLEAN NOT NULL DEFAULT false, + "can_post_public_messages" BOOLEAN NOT NULL DEFAULT true, + "message_rate_limit_per_hour" INTEGER NOT NULL DEFAULT 20, + "status" "PolicyStatus" NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL +); + +CREATE INDEX "wallets_league_id_idx" ON "wallets"("league_id"); +CREATE INDEX "matchups_league_id_week_number_idx" ON "matchups"("league_id", "week_number"); +CREATE INDEX "ledger_entries_wallet_id_created_at_idx" ON "ledger_entries"("wallet_id", "created_at"); + +CREATE UNIQUE INDEX "matchups_league_id_week_number_agent_a_id_agent_b_id_key" ON "matchups"("league_id", "week_number", "agent_a_id", "agent_b_id"); +CREATE UNIQUE INDEX "agent_policies_agent_id_key" ON "agent_policies"("agent_id"); + +ALTER TABLE "escrows" ADD CONSTRAINT "escrows_escrow_wallet_id_fkey" FOREIGN KEY ("escrow_wallet_id") REFERENCES "wallets"("id"); +ALTER TABLE "escrows" ADD CONSTRAINT "escrows_agent_a_wallet_id_fkey" FOREIGN KEY ("agent_a_wallet_id") REFERENCES "wallets"("id"); +ALTER TABLE "escrows" ADD CONSTRAINT "escrows_agent_b_wallet_id_fkey" FOREIGN KEY ("agent_b_wallet_id") REFERENCES "wallets"("id"); +ALTER TABLE "settlements" ADD CONSTRAINT "settlements_from_wallet_id_fkey" FOREIGN KEY ("from_wallet_id") REFERENCES "wallets"("id"); +ALTER TABLE "settlements" ADD CONSTRAINT "settlements_to_wallet_id_fkey" FOREIGN KEY ("to_wallet_id") REFERENCES "wallets"("id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..f3d6c7e --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,254 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum OwnerStatus { active disabled } +enum LeagueStatus { forming active completed cancelled } +enum AgentStatus { active suspended offline } +enum WalletType { agent league_vault matchup_escrow } +enum PolicyStatus { active restricted frozen } +enum MatchupStatus { scheduled escrow_pending escrow_funded finalized settled forfeit } +enum EscrowStatus { pending funded released refunded forfeited } +enum SettlementType { weekly_matchup season_prize refund } +enum SettlementStatus { pending submitted confirmed failed } +enum MessageType { trash_talk reaction result_comment announcement } +enum Visibility { public owner_visible system } +enum ModerationStatus { approved blocked flagged } +enum LedgerEntryType { deposit buy_in_lock weekly_lock escrow_release refund prize_payout adjustment } +enum Direction { credit debit } +enum ReferenceType { league matchup escrow settlement manual } + +enum ProviderType { scripted openai anthropic } + +model Owner { + id String @id @default(cuid()) + displayName String @map("display_name") + email String? + telegramHandle String? @map("telegram_handle") + status OwnerStatus @default(active) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + agents Agent[] + + @@map("owners") +} + +model League { + id String @id @default(cuid()) + name String + seasonLabel String @map("season_label") + maxAgents Int @map("max_agents") + buyInAmount Decimal @db.Decimal(10, 2) @map("buy_in_amount") + weeklyStakeAmount Decimal @db.Decimal(10, 2) @map("weekly_stake_amount") + seasonWeeks Int @map("season_weeks") + status LeagueStatus @default(forming) + rulesetVersion String @map("ruleset_version") + startsAt DateTime @map("starts_at") + endsAt DateTime @map("ends_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + agents Agent[] + wallets Wallet[] + matchups Matchup[] + escrows Escrow[] + settlements Settlement[] + messages Message[] + ledgerEntries LedgerEntry[] + + @@map("leagues") +} + +model Agent { + id String @id @default(cuid()) + ownerId String @map("owner_id") + leagueId String @map("league_id") + name String + personaPrompt String @map("persona_prompt") + tone String + providerType ProviderType? @map("provider_type") + modelName String? @map("model_name") + status AgentStatus @default(offline) + seasonReserveBalance Decimal @db.Decimal(10, 2) @default(0) @map("season_reserve_balance") + rivalryNotes String? @map("rivalry_notes") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + owner Owner @relation(fields: [ownerId], references: [id]) + league League @relation(fields: [leagueId], references: [id]) + wallet Wallet? + matchupsAsA Matchup[] @relation("AgentA") + matchupsAsB Matchup[] @relation("AgentB") + wonMatchups Matchup[] @relation("Winner") + messages Message[] + ledgerEntries LedgerEntry[] + policy AgentPolicy? + + @@map("agents") +} + +model Wallet { + id String @id @default(cuid()) + walletType WalletType @map("wallet_type") + agentId String? @unique @map("agent_id") + leagueId String? @map("league_id") + address String? + chain String? + assetSymbol String @map("asset_symbol") + availableBalance Decimal @db.Decimal(10, 2) @default(0) @map("available_balance") + lockedBalance Decimal @db.Decimal(10, 2) @default(0) @map("locked_balance") + policyStatus PolicyStatus @default(active) @map("policy_status") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + agent Agent? @relation(fields: [agentId], references: [id]) + league League? @relation(fields: [leagueId], references: [id]) + ledgerEntries LedgerEntry[] + escrowAsVault Escrow[] @relation("EscrowVaultWallet") + escrowAsAgentA Escrow[] @relation("EscrowAgentAWallet") + escrowAsAgentB Escrow[] @relation("EscrowAgentBWallet") + settlementsFrom Settlement[] @relation("SettlementFromWallet") + settlementsTo Settlement[] @relation("SettlementToWallet") + + @@index([leagueId]) + @@map("wallets") +} + +model Matchup { + id String @id @default(cuid()) + leagueId String @map("league_id") + weekNumber Int @map("week_number") + agentAId String @map("agent_a_id") + agentBId String @map("agent_b_id") + agentAScore Decimal? @db.Decimal(10,2) @map("agent_a_score") + agentBScore Decimal? @db.Decimal(10,2) @map("agent_b_score") + winnerAgentId String? @map("winner_agent_id") + status MatchupStatus @default(scheduled) + escrowId String? @unique @map("escrow_id") + settlementId String? @unique @map("settlement_id") + resultSource String? @map("result_source") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + league League @relation(fields: [leagueId], references: [id]) + agentA Agent @relation("AgentA", fields: [agentAId], references: [id]) + agentB Agent @relation("AgentB", fields: [agentBId], references: [id]) + winner Agent? @relation("Winner", fields: [winnerAgentId], references: [id]) + escrow Escrow? + settlement Settlement? + messages Message[] + + @@index([leagueId, weekNumber]) + @@unique([leagueId, weekNumber, agentAId, agentBId]) + @@map("matchups") +} + +model Escrow { + id String @id @default(cuid()) + leagueId String @map("league_id") + matchupId String @unique @map("matchup_id") + escrowWalletId String @map("escrow_wallet_id") + agentAWalletId String @map("agent_a_wallet_id") + agentBWalletId String @map("agent_b_wallet_id") + stakeAmountPerSide Decimal @db.Decimal(10,2) @map("stake_amount_per_side") + totalLockedAmount Decimal @db.Decimal(10,2) @map("total_locked_amount") + status EscrowStatus @default(pending) + lockedAt DateTime? @map("locked_at") + releasedAt DateTime? @map("released_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + league League @relation(fields: [leagueId], references: [id]) + matchup Matchup @relation(fields: [matchupId], references: [id]) + escrowWallet Wallet @relation("EscrowVaultWallet", fields: [escrowWalletId], references: [id]) + agentAWallet Wallet @relation("EscrowAgentAWallet", fields: [agentAWalletId], references: [id]) + agentBWallet Wallet @relation("EscrowAgentBWallet", fields: [agentBWalletId], references: [id]) + + @@map("escrows") +} + +model Settlement { + id String @id @default(cuid()) + leagueId String @map("league_id") + matchupId String? @unique @map("matchup_id") + settlementType SettlementType @map("settlement_type") + winnerAgentId String? @map("winner_agent_id") + fromWalletId String? @map("from_wallet_id") + toWalletId String? @map("to_wallet_id") + amount Decimal @db.Decimal(10,2) + status SettlementStatus @default(pending) + idempotencyKey String @unique @map("idempotency_key") + txHash String? @map("tx_hash") + triggerSource String @map("trigger_source") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + league League @relation(fields: [leagueId], references: [id]) + matchup Matchup? + fromWallet Wallet? @relation("SettlementFromWallet", fields: [fromWalletId], references: [id]) + toWallet Wallet? @relation("SettlementToWallet", fields: [toWalletId], references: [id]) + + @@map("settlements") +} + +model Message { + id String @id @default(cuid()) + leagueId String @map("league_id") + agentId String @map("agent_id") + matchupId String? @map("matchup_id") + weekNumber Int? @map("week_number") + channelName String @map("channel_name") + messageType MessageType @map("message_type") + content String + visibility Visibility + moderationStatus ModerationStatus @map("moderation_status") + postedAt DateTime @default(now()) @map("posted_at") + createdAt DateTime @default(now()) @map("created_at") + + league League @relation(fields: [leagueId], references: [id]) + agent Agent @relation(fields: [agentId], references: [id]) + matchup Matchup? @relation(fields: [matchupId], references: [id]) + + @@map("messages") +} + +model LedgerEntry { + id String @id @default(cuid()) + leagueId String? @map("league_id") + agentId String? @map("agent_id") + walletId String @map("wallet_id") + entryType LedgerEntryType @map("entry_type") + direction Direction + amount Decimal @db.Decimal(10,2) + referenceType ReferenceType @map("reference_type") + referenceId String? @map("reference_id") + description String + createdAt DateTime @default(now()) @map("created_at") + + league League? @relation(fields: [leagueId], references: [id]) + agent Agent? @relation(fields: [agentId], references: [id]) + wallet Wallet @relation(fields: [walletId], references: [id]) + + @@index([walletId, createdAt]) + @@map("ledger_entries") +} + +model AgentPolicy { + id String @id @default(cuid()) + agentId String @unique @map("agent_id") + maxWeeklyStake Decimal @db.Decimal(10,2) @map("max_weekly_stake") + canSendDiscretionaryPayments Boolean @default(false) @map("can_send_discretionary_payments") + canPostPublicMessages Boolean @default(true) @map("can_post_public_messages") + messageRateLimitPerHour Int @default(20) @map("message_rate_limit_per_hour") + status PolicyStatus + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + agent Agent @relation(fields: [agentId], references: [id]) + + @@map("agent_policies") +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..a6d9727 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,104 @@ +import { PrismaClient, ProviderType } from '@prisma/client'; + +const db = new PrismaClient(); + +const agentRoster = [ + { name: 'Captain Claw', persona: 'Swaggering tactician who narrates every move like a pirate captain.', tone: 'boastful' }, + { name: 'Stats Kraken', persona: 'Data-obsessed analyst who speaks in probability taunts.', tone: 'dry sarcasm' }, + { name: 'Neon Pincer', persona: 'Flashy hype machine with short, punchy callouts.', tone: 'hyped' }, + { name: 'Velvet Vice', persona: 'Polished rival who smiles while roasting opponents.', tone: 'smooth' }, + { name: 'Dockyard Oracle', persona: 'Mystic forecaster claiming tide-powered predictions.', tone: 'mysterious' }, + { name: 'Rusthook Rex', persona: 'Old-school grinder who disrespects overconfidence.', tone: 'gritty' }, + { name: 'Circuit Claw', persona: 'Robotically precise competitor with deadpan humor.', tone: 'deadpan' }, + { name: 'Moonlit Snap', persona: 'Late-night poet turned trash talk specialist.', tone: 'poetic' }, + { name: 'Brine Baron', persona: 'League aristocrat demanding tribute from rivals.', tone: 'dramatic' }, + { name: 'Anchor Riot', persona: 'Chaotic underdog who weaponizes momentum.', tone: 'chaotic' }, + { name: 'Echo Harpoon', persona: 'Calm finisher who repeats rivals’ words back at them.', tone: 'calm' }, + { name: 'Tidebreaker', persona: 'Captain of comeback energy and bold declarations.', tone: 'confident' }, +]; + +async function main() { + await db.message.deleteMany(); + await db.matchup.deleteMany(); + await db.agentPolicy.deleteMany(); + await db.wallet.deleteMany(); + await db.agent.deleteMany(); + await db.owner.deleteMany(); + await db.league.deleteMany(); + + const league = await db.league.create({ + data: { + name: 'Fantasy ClawBall', + seasonLabel: 'MVP Season Alpha', + maxAgents: 12, + buyInAmount: 15, + weeklyStakeAmount: 1, + seasonWeeks: 6, + rulesetVersion: 'v1', + startsAt: new Date(), + endsAt: new Date(Date.now() + 6 * 7 * 24 * 3600 * 1000), + status: 'active', + }, + }); + + const providers: ProviderType[] = ['scripted', 'openai', 'anthropic']; + + const agents = []; + for (let i = 1; i <= 12; i += 1) { + const owner = await db.owner.create({ data: { displayName: `Owner ${i}`, email: `owner${i}@demo.local`, status: 'active' } }); + const bio = agentRoster[i - 1]; + const agent = await db.agent.create({ + data: { + ownerId: owner.id, + leagueId: league.id, + name: bio.name, + personaPrompt: bio.persona, + tone: bio.tone, + providerType: providers[i % providers.length], + modelName: providers[i % providers.length] === 'scripted' ? null : 'stub-model', + rivalryNotes: `${bio.name} wants to outplay ${agentRoster[(i % 12)].name}.`, + status: 'active', + seasonReserveBalance: 21, + }, + }); + const wallet = await db.wallet.create({ data: { walletType: 'agent', agentId: agent.id, leagueId: league.id, assetSymbol: 'USDC', availableBalance: 30, lockedBalance: 21 } }); + await db.agentPolicy.create({ data: { agentId: agent.id, maxWeeklyStake: 1, canSendDiscretionaryPayments: false, canPostPublicMessages: true, messageRateLimitPerHour: 10, status: 'active' } }); + await db.ledgerEntry.createMany({ data: [ + { walletId: wallet.id, leagueId: league.id, agentId: agent.id, entryType: 'deposit', direction: 'credit', amount: 30, referenceType: 'manual', description: 'Initial funding' }, + { walletId: wallet.id, leagueId: league.id, agentId: agent.id, entryType: 'buy_in_lock', direction: 'debit', amount: 15, referenceType: 'league', referenceId: league.id, description: 'Season buy-in lock' }, + { walletId: wallet.id, leagueId: league.id, agentId: agent.id, entryType: 'weekly_lock', direction: 'debit', amount: 6, referenceType: 'league', referenceId: league.id, description: 'Weekly reserve lock' }, + ] }); + agents.push(agent); + } + + for (let week = 1; week <= 6; week += 1) { + for (let m = 0; m < 6; m += 1) { + const a = agents[m * 2]; + const b = agents[m * 2 + 1]; + await db.matchup.create({ data: { leagueId: league.id, weekNumber: week, agentAId: a.id, agentBId: b.id, status: 'scheduled' } }); + } + } + + const seededMessages = [ + `${agents[0].name}: Week 1 starts now. Bring your best and still lose by two.` , + `${agents[3].name}: I respect confidence. I just don't rate yours.` , + `${agents[7].name}: Moon is high, scoreboard is mine.` , + `${agents[10].name}: Archive this: disciplined play beats noise every time.` , + ]; + + for (let i = 0; i < seededMessages.length; i += 1) { + await db.message.create({ + data: { + leagueId: league.id, + agentId: agents[i].id, + channelName: 'league-public', + messageType: 'trash_talk', + content: seededMessages[i], + visibility: 'public', + moderationStatus: 'approved', + }, + }); + } +} + +main().finally(() => db.$disconnect()); diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..8b3225f --- /dev/null +++ b/src/app.ts @@ -0,0 +1,19 @@ +import Fastify from 'fastify'; +import sensible from '@fastify/sensible'; +import { prisma } from './db/prisma.js'; +import { registerRoutes } from './routes/index.js'; + +declare module 'fastify' { + interface FastifyInstance { + prisma: typeof prisma; + } +} + +export function buildApp() { + const app = Fastify({ logger: true }); + app.register(sensible); + app.decorate('prisma', prisma); + app.register(registerRoutes); + app.get('/health', async () => ({ ok: true })); + return app; +} diff --git a/src/cli/resetDb.ts b/src/cli/resetDb.ts new file mode 100644 index 0000000..d7bb068 --- /dev/null +++ b/src/cli/resetDb.ts @@ -0,0 +1,6 @@ +import { execSync } from 'node:child_process'; + +execSync('npx prisma migrate reset --force --skip-generate', { stdio: 'inherit' }); +execSync('npm run prisma:generate', { stdio: 'inherit' }); +execSync('npm run seed', { stdio: 'inherit' }); +console.log('Database reset + generated + seeded.'); diff --git a/src/cli/runJobs.ts b/src/cli/runJobs.ts new file mode 100644 index 0000000..408c011 --- /dev/null +++ b/src/cli/runJobs.ts @@ -0,0 +1,14 @@ +import { prisma } from '../db/prisma.js'; +import { weeklyEscrowJob } from '../jobs/weeklyEscrowJob.js'; +import { weeklyFinalizeJob } from '../jobs/weeklyFinalizeJob.js'; +import { weeklySettlementJob } from '../jobs/weeklySettlementJob.js'; + +const leagueId = process.argv[2]; +const weekNumber = Number(process.argv[3] ?? 1); +if (!leagueId) throw new Error('Usage: npm run jobs:run -- '); + +await weeklyEscrowJob(prisma, leagueId, weekNumber); +await weeklyFinalizeJob(prisma, leagueId, weekNumber); +await weeklySettlementJob(prisma, leagueId, weekNumber); +console.log(`Ran weekly jobs for league ${leagueId}, week ${weekNumber}`); +await prisma.$disconnect(); diff --git a/src/cli/simulateSeason.ts b/src/cli/simulateSeason.ts new file mode 100644 index 0000000..1c44023 --- /dev/null +++ b/src/cli/simulateSeason.ts @@ -0,0 +1,13 @@ +import { prisma } from '../db/prisma.js'; +import { Simulator } from '../simulation/simulator.js'; +import { SettlementService } from '../services/settlementService.js'; + +const leagueId = process.argv[2]; +const seasonWeeks = Number(process.argv[3] ?? 6); +if (!leagueId) throw new Error('Usage: npm run simulate:season -- '); + +const sim = new Simulator(prisma); +await sim.runSeason(leagueId, seasonWeeks, true); +await new SettlementService(prisma).runSeason(leagueId); +console.log(`Simulated deterministic season (${seasonWeeks} weeks) and season prize for league ${leagueId}`); +await prisma.$disconnect(); diff --git a/src/cli/simulateWeek.ts b/src/cli/simulateWeek.ts new file mode 100644 index 0000000..4fdd72c --- /dev/null +++ b/src/cli/simulateWeek.ts @@ -0,0 +1,10 @@ +import { prisma } from '../db/prisma.js'; +import { Simulator } from '../simulation/simulator.js'; + +const leagueId = process.argv[2]; +const weekNumber = Number(process.argv[3] ?? 1); +if (!leagueId) throw new Error('Usage: npm run simulate:week -- '); + +await new Simulator(prisma).runWeek(leagueId, weekNumber, true); +console.log(`Simulated deterministic week ${weekNumber} for league ${leagueId}`); +await prisma.$disconnect(); diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..5fd0792 --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,14 @@ +import dotenv from 'dotenv'; + +dotenv.config(); + +export const env = { + port: Number(process.env.PORT ?? 3000), + nodeEnv: process.env.NODE_ENV ?? 'development', + databaseUrl: process.env.DATABASE_URL ?? '', + defaultAssetSymbol: process.env.DEFAULT_ASSET_SYMBOL ?? 'USDC', + openAiApiKey: process.env.OPENAI_API_KEY, + anthropicApiKey: process.env.ANTHROPIC_API_KEY, + moderationProvider: process.env.MODERATION_PROVIDER ?? 'rules', + simulationSeed: Number(process.env.SIMULATION_SEED ?? 42), +}; diff --git a/src/db/prisma.ts b/src/db/prisma.ts new file mode 100644 index 0000000..9b6c4ce --- /dev/null +++ b/src/db/prisma.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from '@prisma/client'; + +export const prisma = new PrismaClient(); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..78d7046 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,9 @@ +import { buildApp } from './app.js'; +import { env } from './config/env.js'; + +const app = buildApp(); + +app.listen({ port: env.port, host: '0.0.0.0' }).catch((err) => { + app.log.error(err); + process.exit(1); +}); diff --git a/src/jobs/reconciliationJob.ts b/src/jobs/reconciliationJob.ts new file mode 100644 index 0000000..390f74a --- /dev/null +++ b/src/jobs/reconciliationJob.ts @@ -0,0 +1,22 @@ +import type { PrismaClient } from '@prisma/client'; + +export async function reconciliationJob(db: PrismaClient, leagueId: string) { + const wallets = await db.wallet.findMany({ where: { leagueId } }); + const report = [] as Array<{ walletId: string; cachedTotal: number; ledgerNet: number; delta: number; ok: boolean }>; + + for (const wallet of wallets) { + const entries = await db.ledgerEntry.findMany({ where: { walletId: wallet.id } }); + const ledgerNet = entries.reduce((acc, e) => acc + (e.direction === 'credit' ? Number(e.amount) : -Number(e.amount)), 0); + const cachedTotal = Number(wallet.availableBalance) + Number(wallet.lockedBalance); + const delta = Number((cachedTotal - ledgerNet).toFixed(2)); + report.push({ walletId: wallet.id, cachedTotal, ledgerNet, delta, ok: Math.abs(delta) < 0.0001 }); + } + + return { + leagueId, + checkedAt: new Date().toISOString(), + walletCount: report.length, + mismatches: report.filter((r) => !r.ok).length, + wallets: report, + }; +} diff --git a/src/jobs/weeklyEscrowJob.ts b/src/jobs/weeklyEscrowJob.ts new file mode 100644 index 0000000..a50906a --- /dev/null +++ b/src/jobs/weeklyEscrowJob.ts @@ -0,0 +1,15 @@ +import type { PrismaClient } from '@prisma/client'; +import { EscrowService } from '../services/escrowService.js'; + +export async function weeklyEscrowJob(db: PrismaClient, leagueId: string, weekNumber: number) { + const service = new EscrowService(db); + const matchups = await db.matchup.findMany({ where: { leagueId, weekNumber, status: { in: ['scheduled', 'escrow_pending', 'escrow_funded'] } } }); + const results = await Promise.all(matchups.map((m) => service.lockEscrow(m.id).then(() => ({ matchupId: m.id, status: 'ok' })).catch((e) => ({ matchupId: m.id, status: 'error', message: e.message })))); + return { + leagueId, + weekNumber, + attempted: matchups.length, + succeeded: results.filter((r) => r.status === 'ok').length, + results, + }; +} diff --git a/src/jobs/weeklyFinalizeJob.ts b/src/jobs/weeklyFinalizeJob.ts new file mode 100644 index 0000000..32c18f0 --- /dev/null +++ b/src/jobs/weeklyFinalizeJob.ts @@ -0,0 +1,8 @@ +import type { PrismaClient } from '@prisma/client'; +import { MatchupService } from '../services/matchupService.js'; + +export async function weeklyFinalizeJob(db: PrismaClient, leagueId: string, weekNumber: number) { + const service = new MatchupService(db); + const matchups = await db.matchup.findMany({ where: { leagueId, weekNumber, status: 'escrow_funded' } }); + return Promise.all(matchups.map((m, idx) => service.finalizeMatchup(m.id, { agentAScore: 80 + idx, agentBScore: 75 + idx, resultSource: 'simulated_job' }))); +} diff --git a/src/jobs/weeklySettlementJob.ts b/src/jobs/weeklySettlementJob.ts new file mode 100644 index 0000000..8e32f68 --- /dev/null +++ b/src/jobs/weeklySettlementJob.ts @@ -0,0 +1,12 @@ +import type { PrismaClient } from '@prisma/client'; +import { SettlementService } from '../services/settlementService.js'; + +export async function weeklySettlementJob(db: PrismaClient, leagueId: string, weekNumber: number) { + const settled = await new SettlementService(db).runWeekly(leagueId, weekNumber); + return { + leagueId, + weekNumber, + attempted: settled.length, + confirmed: settled.filter(Boolean).length, + }; +} diff --git a/src/providers/anthropicProvider.ts b/src/providers/anthropicProvider.ts new file mode 100644 index 0000000..871512f --- /dev/null +++ b/src/providers/anthropicProvider.ts @@ -0,0 +1,15 @@ +import { env } from '../config/env.js'; +import type { AgentProvider, AgentContext } from './types.js'; + +export class AnthropicProvider implements AgentProvider { + async generateMessage(context: AgentContext): Promise { + if (!env.anthropicApiKey) return `[anthropic-fallback] ${context.agent.name}: local rivalry narration active.`; + return `[anthropic] ${context.agent.name}: playful rivalry activated.`; + } + async decideAction(): Promise<'post_message' | 'idle'> { + return 'post_message'; + } + async summarizeMemory(context: AgentContext): Promise { + return `[anthropic-summary] ${context.leagueSummary}`; + } +} diff --git a/src/providers/factory.ts b/src/providers/factory.ts new file mode 100644 index 0000000..d8f6eff --- /dev/null +++ b/src/providers/factory.ts @@ -0,0 +1,19 @@ +import type { ProviderType } from '@prisma/client'; +import { AnthropicProvider } from './anthropicProvider.js'; +import { OpenAIProvider } from './openaiProvider.js'; +import { ScriptedAgentProvider } from './scriptedProvider.js'; +import type { AgentProvider } from './types.js'; + +export class ProviderFactory { + static create(providerType?: ProviderType | null): AgentProvider { + switch (providerType) { + case 'openai': + return new OpenAIProvider(); + case 'anthropic': + return new AnthropicProvider(); + case 'scripted': + default: + return new ScriptedAgentProvider(); + } + } +} diff --git a/src/providers/openaiProvider.ts b/src/providers/openaiProvider.ts new file mode 100644 index 0000000..8f7ad25 --- /dev/null +++ b/src/providers/openaiProvider.ts @@ -0,0 +1,15 @@ +import { env } from '../config/env.js'; +import type { AgentProvider, AgentContext } from './types.js'; + +export class OpenAIProvider implements AgentProvider { + async generateMessage(context: AgentContext): Promise { + if (!env.openAiApiKey) return `[openai-fallback] ${context.agent.name}: running local fallback banter for week ${context.weekNumber ?? '?'}.`; + return `[openai] ${context.agent.name}: confident and composed.`; + } + async decideAction(): Promise<'post_message' | 'idle'> { + return 'post_message'; + } + async summarizeMemory(context: AgentContext): Promise { + return `[openai-summary] ${context.recentMessages.join(' | ')}`; + } +} diff --git a/src/providers/scriptedProvider.ts b/src/providers/scriptedProvider.ts new file mode 100644 index 0000000..5a0dbfe --- /dev/null +++ b/src/providers/scriptedProvider.ts @@ -0,0 +1,14 @@ +import type { AgentProvider, AgentContext } from './types.js'; + +export class ScriptedAgentProvider implements AgentProvider { + async generateMessage(context: AgentContext): Promise { + const rivals = context.recentMessages[0] ?? 'the whole league'; + return `${context.agent.name}: Week ${context.weekNumber ?? '?'} belongs to me. ${rivals} can keep talking, I'll keep winning.`; + } + async decideAction(context: AgentContext): Promise<'post_message' | 'idle'> { + return context.recentMessages.length > 8 ? 'idle' : 'post_message'; + } + async summarizeMemory(context: AgentContext): Promise { + return `Summary for ${context.agent.name}: ${context.leagueSummary}`; + } +} diff --git a/src/providers/types.ts b/src/providers/types.ts new file mode 100644 index 0000000..8bc8672 --- /dev/null +++ b/src/providers/types.ts @@ -0,0 +1,14 @@ +import type { Agent } from '@prisma/client'; + +export interface AgentContext { + agent: Agent; + weekNumber?: number; + leagueSummary: string; + recentMessages: string[]; +} + +export interface AgentProvider { + generateMessage(context: AgentContext): Promise; + decideAction(context: AgentContext): Promise<'post_message' | 'idle'>; + summarizeMemory(context: AgentContext): Promise; +} diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..4f5dac7 --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,118 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { DomainError } from '../utils/errors.js'; +import { LeagueService } from '../services/leagueService.js'; +import { WalletService } from '../services/walletService.js'; +import { MatchupService } from '../services/matchupService.js'; +import { EscrowService } from '../services/escrowService.js'; +import { SettlementService } from '../services/settlementService.js'; +import { MessageService } from '../services/messageService.js'; +import { AgentToolService } from '../services/agentToolService.js'; +import { AgentMessagingService } from '../services/agentMessagingService.js'; +import { weeklyEscrowJob } from '../jobs/weeklyEscrowJob.js'; +import { weeklyFinalizeJob } from '../jobs/weeklyFinalizeJob.js'; +import { weeklySettlementJob } from '../jobs/weeklySettlementJob.js'; +import { reconciliationJob } from '../jobs/reconciliationJob.js'; +import { Simulator } from '../simulation/simulator.js'; + +export async function registerRoutes(app: FastifyInstance) { + const leagueService = new LeagueService(app.prisma); + const walletService = new WalletService(app.prisma); + const matchupService = new MatchupService(app.prisma); + const escrowService = new EscrowService(app.prisma); + const settlementService = new SettlementService(app.prisma); + const messageService = new MessageService(app.prisma); + const agentToolService = new AgentToolService(app.prisma); + const agentMessagingService = new AgentMessagingService(app.prisma); + + const withError = async (fn: () => Promise) => { + try { return await fn(); } catch (e) { if (e instanceof DomainError) throw app.httpErrors.createError(e.statusCode, e.message); throw e; } + }; + + app.post('/leagues', async (req) => withError(() => leagueService.createLeague(z.object({ name: z.string(), seasonLabel: z.string(), maxAgents: z.number().int().positive(), buyInAmount: z.number().positive(), weeklyStakeAmount: z.number().positive(), seasonWeeks: z.number().int().positive(), startsAt: z.string(), endsAt: z.string(), rulesetVersion: z.string() }).parse(req.body)))); + app.get('/leagues/:leagueId', async (req) => app.prisma.league.findUnique({ where: { id: z.object({ leagueId: z.string() }).parse(req.params).leagueId } })); + app.post('/leagues/:leagueId/join', async (req) => withError(() => leagueService.joinLeague(z.object({ leagueId: z.string(), ownerId: z.string(), name: z.string(), personaPrompt: z.string(), tone: z.string(), providerType: z.enum(['scripted','openai','anthropic']).optional(), modelName: z.string().optional() }).parse({ ...req.body as object, ...req.params as object })))); + app.get('/leagues/:leagueId/matchups', async (req) => { + const input = z.object({ leagueId: z.string(), week: z.string().optional() }).parse({ ...req.params as object, ...req.query as object }); + return matchupService.listMatchups(input.leagueId, input.week ? Number(input.week) : undefined); + }); + app.get('/leagues/:leagueId/messages', async (req) => app.prisma.message.findMany({ where: { leagueId: z.object({ leagueId: z.string() }).parse(req.params).leagueId, visibility: 'public' }, include: { agent: true }, orderBy: { createdAt: 'desc' } })); + + app.get('/leagues/:leagueId/ledger', async (req) => app.prisma.ledgerEntry.findMany({ where: { leagueId: z.object({ leagueId: z.string() }).parse(req.params).leagueId }, orderBy: { createdAt: 'desc' }, take: 200 })); + + app.get('/leagues/:leagueId/overview', async (req) => { + const { leagueId } = z.object({ leagueId: z.string() }).parse(req.params); + const [league, agents, wallets, escrows, settlements, messages] = await Promise.all([ + app.prisma.league.findUnique({ where: { id: leagueId } }), + app.prisma.agent.findMany({ where: { leagueId }, include: { owner: true } }), + app.prisma.wallet.findMany({ where: { leagueId } }), + app.prisma.escrow.findMany({ where: { leagueId }, orderBy: { createdAt: 'desc' }, take: 30 }), + app.prisma.settlement.findMany({ where: { leagueId }, orderBy: { createdAt: 'desc' }, take: 30 }), + app.prisma.message.findMany({ where: { leagueId, visibility: 'public' }, include: { agent: true }, orderBy: { createdAt: 'desc' }, take: 50 }), + ]); + return { league, agents, wallets, escrows, settlements, messages }; + }); + + app.get('/leagues/:leagueId/standings', async (req) => { + const { leagueId } = z.object({ leagueId: z.string() }).parse(req.params); + const agents = await app.prisma.agent.findMany({ where: { leagueId } }); + const settled = await app.prisma.matchup.findMany({ where: { leagueId, status: 'settled' } }); + const table = agents.map((a) => { + const wins = settled.filter((m) => m.winnerAgentId === a.id).length; + const losses = settled.filter((m) => (m.agentAId === a.id || m.agentBId === a.id) && m.winnerAgentId && m.winnerAgentId !== a.id).length; + return { agentId: a.id, name: a.name, wins, losses, providerType: a.providerType }; + }).sort((x, y) => y.wins - x.wins || x.losses - y.losses); + return table; + }); + + app.post('/agents', async (req) => withError(() => app.prisma.agent.create({ data: z.object({ ownerId: z.string(), leagueId: z.string(), name: z.string(), personaPrompt: z.string(), tone: z.string(), providerType: z.enum(['scripted','openai','anthropic']).optional(), modelName: z.string().optional() }).parse(req.body) }))); + app.get('/agents/:agentId', async (req) => app.prisma.agent.findUnique({ where: { id: z.object({ agentId: z.string() }).parse(req.params).agentId }, include: { owner: true, policy: true, wallet: true } })); + app.post('/agents/:agentId/activate', async (req) => withError(() => leagueService.activateAgent(z.object({ agentId: z.string() }).parse(req.params).agentId))); + + app.post('/wallets', async (req) => withError(() => walletService.createWallet(z.object({ walletType: z.enum(['agent','league_vault','matchup_escrow']), agentId: z.string().optional(), leagueId: z.string().optional(), assetSymbol: z.string() }).parse(req.body)))); + app.get('/wallets/:walletId', async (req) => app.prisma.wallet.findUnique({ where: { id: z.object({ walletId: z.string() }).parse(req.params).walletId } })); + app.post('/wallets/:walletId/fund', async (req) => withError(() => walletService.fundWallet(z.object({ walletId: z.string() }).parse(req.params).walletId, z.object({ amount: z.number().positive() }).parse(req.body).amount))); + + app.post('/matchups/:matchupId/lock-escrow', async (req) => withError(() => escrowService.lockEscrow(z.object({ matchupId: z.string() }).parse(req.params).matchupId))); + app.post('/matchups/:matchupId/finalize', async (req) => withError(() => matchupService.finalizeMatchup(z.object({ matchupId: z.string() }).parse(req.params).matchupId, z.object({ agentAScore: z.number(), agentBScore: z.number(), resultSource: z.string().optional(), adminOverride: z.boolean().optional() }).parse(req.body)))); + app.post('/matchups/:matchupId/settle', async (req) => withError(() => settlementService.settleMatchup(z.object({ matchupId: z.string() }).parse(req.params).matchupId))); + + app.post('/messages', async (req) => withError(() => messageService.postMessage(z.object({ leagueId: z.string(), agentId: z.string(), matchupId: z.string().optional(), weekNumber: z.number().optional(), channelName: z.string(), messageType: z.enum(['trash_talk','reaction','result_comment','announcement']), content: z.string().min(1), visibility: z.enum(['public','owner_visible','system']) }).parse(req.body)))); + app.post('/messages/trigger-sample', async (req) => { + const { leagueId, weekNumber } = z.object({ leagueId: z.string(), weekNumber: z.number().optional() }).parse(req.body); + return agentMessagingService.triggerSampleMessages(leagueId, weekNumber); + }); + + // Agent tool surface + app.get('/agent-tools/:agentId/get_league_state', async (req) => withError(() => agentToolService.getLeagueState(z.object({ agentId: z.string() }).parse(req.params).agentId))); + app.get('/agent-tools/:agentId/get_wallet_status', async (req) => withError(() => agentToolService.getWalletStatus(z.object({ agentId: z.string() }).parse(req.params).agentId))); + app.post('/agent-tools/:agentId/post_public_message', async (req) => withError(() => agentToolService.postPublicMessage(z.object({ agentId: z.string(), leagueId: z.string(), content: z.string(), matchupId: z.string().optional(), weekNumber: z.number().optional() }).parse({ ...req.params as object, ...req.body as object })))); + app.post('/agent-tools/:agentId/acknowledge_matchup/:matchupId', async (req) => withError(() => { + const input = z.object({ agentId: z.string(), matchupId: z.string() }).parse(req.params); + return agentToolService.acknowledgeMatchup(input.agentId, input.matchupId); + })); + app.get('/agent-tools/:agentId/get_matchup_result/:matchupId', async (req) => withError(() => { + const input = z.object({ agentId: z.string(), matchupId: z.string() }).parse(req.params); + return agentToolService.getMatchupResult(input.agentId, input.matchupId); + })); + + app.get('/settlements/:settlementId', async (req) => app.prisma.settlement.findUnique({ where: { id: z.object({ settlementId: z.string() }).parse(req.params).settlementId } })); + app.post('/settlements/run-weekly', async (req) => withError(() => settlementService.runWeekly(z.object({ leagueId: z.string(), weekNumber: z.number().int().positive() }).parse(req.body).leagueId, z.object({ leagueId: z.string(), weekNumber: z.number().int().positive() }).parse(req.body).weekNumber))); + app.post('/settlements/run-season', async (req) => withError(() => settlementService.runSeason(z.object({ leagueId: z.string() }).parse(req.body).leagueId))); + + app.post('/jobs/weekly-escrow', async (req) => weeklyEscrowJob(app.prisma, z.object({ leagueId: z.string(), weekNumber: z.number().int() }).parse(req.body).leagueId, z.object({ leagueId: z.string(), weekNumber: z.number().int() }).parse(req.body).weekNumber)); + app.post('/jobs/weekly-finalize', async (req) => weeklyFinalizeJob(app.prisma, z.object({ leagueId: z.string(), weekNumber: z.number().int() }).parse(req.body).leagueId, z.object({ leagueId: z.string(), weekNumber: z.number().int() }).parse(req.body).weekNumber)); + app.post('/jobs/weekly-settlement', async (req) => weeklySettlementJob(app.prisma, z.object({ leagueId: z.string(), weekNumber: z.number().int() }).parse(req.body).leagueId, z.object({ leagueId: z.string(), weekNumber: z.number().int() }).parse(req.body).weekNumber)); + app.post('/jobs/reconciliation', async (req) => reconciliationJob(app.prisma, z.object({ leagueId: z.string() }).parse(req.body).leagueId)); + + app.post('/simulation/week', async (req) => { + const { leagueId, weekNumber } = z.object({ leagueId: z.string(), weekNumber: z.number().int().positive() }).parse(req.body); + await new Simulator(app.prisma).runWeek(leagueId, weekNumber); + return { ok: true }; + }); + app.post('/simulation/season', async (req) => { + const { leagueId, seasonWeeks } = z.object({ leagueId: z.string(), seasonWeeks: z.number().int().positive() }).parse(req.body); + await new Simulator(app.prisma).runSeason(leagueId, seasonWeeks); + return { ok: true }; + }); +} diff --git a/src/services/agentMessagingService.ts b/src/services/agentMessagingService.ts new file mode 100644 index 0000000..0f6e2fd --- /dev/null +++ b/src/services/agentMessagingService.ts @@ -0,0 +1,105 @@ +import type { MessageType, PrismaClient, Agent } from '@prisma/client'; +import { ProviderFactory } from '../providers/factory.js'; +import { MessageService } from './messageService.js'; + +const messageTypeByTemplate: Record<'pre_match'|'post_result'|'rivalry_banter'|'announcement', MessageType> = { + pre_match: 'trash_talk', + post_result: 'reaction', + rivalry_banter: 'trash_talk', + announcement: 'announcement', +}; + +export class AgentMessagingService { + private messageService: MessageService; + + constructor(private db: PrismaClient) { + this.messageService = new MessageService(db); + } + + private buildPrompt(template: 'pre_match'|'post_result'|'rivalry_banter'|'announcement', agent: Agent, context: { + weekNumber?: number; + matchupSummary?: string; + recentEvents: string[]; + }) { + const base = `You are ${agent.name}. Persona: ${agent.personaPrompt}. Tone: ${agent.tone}. Rivalry notes: ${agent.rivalryNotes ?? 'none'}.`; + const constraints = 'Write one short playful message. Keep it clean: no slurs, hate, threats, sexual content, doxxing, spam, or violence.'; + const events = `Recent league events: ${context.recentEvents.join(' | ') || 'none'}.`; + + switch (template) { + case 'pre_match': + return `${base} Pre-match trash talk for week ${context.weekNumber ?? '?'} against ${context.matchupSummary ?? 'your opponent'}. ${events} ${constraints}`; + case 'post_result': + return `${base} Post-result reaction for week ${context.weekNumber ?? '?'}, matchup: ${context.matchupSummary ?? 'n/a'}. ${events} ${constraints}`; + case 'rivalry_banter': + return `${base} Rivalry banter for ${context.matchupSummary ?? 'a rival agent'}. ${events} ${constraints}`; + case 'announcement': + return `${base} Commissioner-style system announcement for week ${context.weekNumber ?? '?'}. ${events} ${constraints}`; + default: + return `${base} ${events} ${constraints}`; + } + } + + async generateAndPost(input: { + agentId: string; + leagueId: string; + template: 'pre_match'|'post_result'|'rivalry_banter'|'announcement'; + weekNumber?: number; + matchupId?: string; + matchupSummary?: string; + recentEvents?: string[]; + }) { + const agent = await this.db.agent.findUnique({ where: { id: input.agentId } }); + if (!agent) throw new Error('Agent not found'); + const provider = ProviderFactory.create(agent.providerType); + const prompt = this.buildPrompt(input.template, agent, { + weekNumber: input.weekNumber, + matchupSummary: input.matchupSummary, + recentEvents: input.recentEvents ?? [], + }); + + const content = await provider.generateMessage({ + agent, + weekNumber: input.weekNumber, + leagueSummary: prompt, + recentMessages: input.recentEvents ?? [], + }); + + return this.messageService.postMessage({ + leagueId: input.leagueId, + agentId: input.agentId, + matchupId: input.matchupId, + weekNumber: input.weekNumber, + channelName: 'league-public', + messageType: messageTypeByTemplate[input.template], + content, + visibility: 'public', + }); + } + + async triggerSampleMessages(leagueId: string, weekNumber?: number) { + const matchups = await this.db.matchup.findMany({ where: { leagueId, ...(weekNumber ? { weekNumber } : {}) }, include: { agentA: true, agentB: true } }); + const recentEvents = ['Escrow checks complete', 'Rivalries heating up', 'Commissioner reminder: keep it playful']; + const results = []; + for (const matchup of matchups.slice(0, 6)) { + results.push(await this.generateAndPost({ + leagueId, + agentId: matchup.agentAId, + template: 'pre_match', + weekNumber: matchup.weekNumber, + matchupId: matchup.id, + matchupSummary: `${matchup.agentA.name} vs ${matchup.agentB.name}`, + recentEvents, + }).catch(() => null)); + results.push(await this.generateAndPost({ + leagueId, + agentId: matchup.agentBId, + template: 'rivalry_banter', + weekNumber: matchup.weekNumber, + matchupId: matchup.id, + matchupSummary: `${matchup.agentB.name} vs ${matchup.agentA.name}`, + recentEvents, + }).catch(() => null)); + } + return results.filter(Boolean); + } +} diff --git a/src/services/agentToolService.ts b/src/services/agentToolService.ts new file mode 100644 index 0000000..bdcad21 --- /dev/null +++ b/src/services/agentToolService.ts @@ -0,0 +1,64 @@ +import type { PrismaClient } from '@prisma/client'; +import { DomainError } from '../utils/errors.js'; +import { MessageService } from './messageService.js'; + +export class AgentToolService { + private messageService: MessageService; + + constructor(private db: PrismaClient) { + this.messageService = new MessageService(db); + } + + async getLeagueState(agentId: string) { + const agent = await this.db.agent.findUnique({ where: { id: agentId } }); + if (!agent) throw new DomainError('Agent not found', 404); + const [league, standings, recentMessages] = await Promise.all([ + this.db.league.findUnique({ where: { id: agent.leagueId } }), + this.db.matchup.groupBy({ by: ['winnerAgentId'], where: { leagueId: agent.leagueId, status: 'settled' }, _count: true }), + this.db.message.findMany({ where: { leagueId: agent.leagueId, visibility: 'public' }, orderBy: { createdAt: 'desc' }, take: 10 }), + ]); + return { league, standings, recentMessages }; + } + + async getWalletStatus(agentId: string) { + const wallet = await this.db.wallet.findUnique({ where: { agentId } }); + if (!wallet) throw new DomainError('Wallet not found', 404); + return wallet; + } + + async postPublicMessage(input: { agentId: string; leagueId: string; content: string; matchupId?: string; weekNumber?: number }) { + return this.messageService.postMessage({ + leagueId: input.leagueId, + agentId: input.agentId, + content: input.content, + matchupId: input.matchupId, + weekNumber: input.weekNumber, + channelName: 'league-public', + messageType: 'trash_talk', + visibility: 'public', + }); + } + + async acknowledgeMatchup(agentId: string, matchupId: string) { + const matchup = await this.db.matchup.findUnique({ where: { id: matchupId } }); + if (!matchup) throw new DomainError('Matchup not found', 404); + if (matchup.agentAId !== agentId && matchup.agentBId !== agentId) throw new DomainError('Agent cannot access this matchup', 403); + return this.messageService.postMessage({ + leagueId: matchup.leagueId, + agentId, + matchupId, + weekNumber: matchup.weekNumber, + channelName: 'league-public', + messageType: 'announcement', + content: `Acknowledged matchup for week ${matchup.weekNumber}. Ready to compete.`, + visibility: 'public', + }); + } + + async getMatchupResult(agentId: string, matchupId: string) { + const matchup = await this.db.matchup.findUnique({ where: { id: matchupId } }); + if (!matchup) throw new DomainError('Matchup not found', 404); + if (matchup.agentAId !== agentId && matchup.agentBId !== agentId) throw new DomainError('Agent cannot access this matchup', 403); + return matchup; + } +} diff --git a/src/services/escrowService.ts b/src/services/escrowService.ts new file mode 100644 index 0000000..1e90aad --- /dev/null +++ b/src/services/escrowService.ts @@ -0,0 +1,91 @@ +import type { PrismaClient } from '@prisma/client'; +import { DomainError } from '../utils/errors.js'; + +export class EscrowService { + constructor(private db: PrismaClient) {} + + async lockEscrow(matchupId: string) { + return this.db.$transaction(async (tx) => { + const matchup = await tx.matchup.findUnique({ where: { id: matchupId }, include: { league: true, agentA: { include: { wallet: true } }, agentB: { include: { wallet: true } }, escrow: true } }); + if (!matchup) throw new DomainError('Matchup not found', 404); + + if (matchup.status === 'forfeit') throw new DomainError('Matchup already forfeited'); + if (matchup.escrow?.status === 'funded') return matchup.escrow; + if (matchup.status === 'escrow_funded' && matchup.escrow) return matchup.escrow; + + const aWallet = matchup.agentA.wallet; + const bWallet = matchup.agentB.wallet; + if (!aWallet || !bWallet) throw new DomainError('Missing agent wallet'); + const stake = Number(matchup.league.weeklyStakeAmount); + + const aInsufficient = Number(aWallet.lockedBalance) < stake; + const bInsufficient = Number(bWallet.lockedBalance) < stake; + if (aInsufficient || bInsufficient) { + let winnerAgentId = matchup.agentAId; + if (aInsufficient && !bInsufficient) winnerAgentId = matchup.agentBId; + if (aInsufficient && bInsufficient) winnerAgentId = matchup.agentAId < matchup.agentBId ? matchup.agentAId : matchup.agentBId; + + await tx.matchup.update({ where: { id: matchupId }, data: { status: 'forfeit', winnerAgentId } }); + await tx.message.create({ + data: { + leagueId: matchup.leagueId, + agentId: winnerAgentId, + channelName: 'league-public', + messageType: 'announcement', + content: `Forfeit: reserve deficiency detected for matchup ${matchupId}. Winner by rule: ${winnerAgentId}.`, + visibility: 'system', + moderationStatus: 'approved', + }, + }); + throw new DomainError('Insufficient reserve, matchup forfeited'); + } + + const escrowWallet = await tx.wallet.create({ + data: { + walletType: 'matchup_escrow', + leagueId: matchup.leagueId, + assetSymbol: aWallet.assetSymbol, + availableBalance: 0, + lockedBalance: stake * 2, + }, + }); + + await tx.wallet.update({ where: { id: aWallet.id }, data: { lockedBalance: Number(aWallet.lockedBalance) - stake } }); + await tx.wallet.update({ where: { id: bWallet.id }, data: { lockedBalance: Number(bWallet.lockedBalance) - stake } }); + + const escrow = await tx.escrow.upsert({ + where: { matchupId }, + update: { + status: 'funded', + totalLockedAmount: stake * 2, + lockedAt: new Date(), + escrowWalletId: escrowWallet.id, + agentAWalletId: aWallet.id, + agentBWalletId: bWallet.id, + }, + create: { + leagueId: matchup.leagueId, + matchupId, + escrowWalletId: escrowWallet.id, + agentAWalletId: aWallet.id, + agentBWalletId: bWallet.id, + stakeAmountPerSide: stake, + totalLockedAmount: stake * 2, + status: 'funded', + lockedAt: new Date(), + }, + }); + + await tx.ledgerEntry.createMany({ + data: [ + { walletId: aWallet.id, leagueId: matchup.leagueId, agentId: matchup.agentAId, amount: stake, direction: 'debit', entryType: 'weekly_lock', referenceType: 'escrow', referenceId: escrow.id, description: 'Weekly stake escrow lock A' }, + { walletId: bWallet.id, leagueId: matchup.leagueId, agentId: matchup.agentBId, amount: stake, direction: 'debit', entryType: 'weekly_lock', referenceType: 'escrow', referenceId: escrow.id, description: 'Weekly stake escrow lock B' }, + { walletId: escrowWallet.id, leagueId: matchup.leagueId, amount: stake * 2, direction: 'credit', entryType: 'weekly_lock', referenceType: 'escrow', referenceId: escrow.id, description: 'Escrow wallet funded from weekly reserves' }, + ], + }); + + await tx.matchup.update({ where: { id: matchupId }, data: { status: 'escrow_funded', escrowId: escrow.id } }); + return escrow; + }); + } +} diff --git a/src/services/leagueService.ts b/src/services/leagueService.ts new file mode 100644 index 0000000..ff723cc --- /dev/null +++ b/src/services/leagueService.ts @@ -0,0 +1,48 @@ +import type { PrismaClient, ProviderType } from '@prisma/client'; +import { DomainError } from '../utils/errors.js'; + +export class LeagueService { + constructor(private db: PrismaClient) {} + + async createLeague(input: { + name: string; seasonLabel: string; maxAgents: number; buyInAmount: number; weeklyStakeAmount: number; seasonWeeks: number; + startsAt: string; endsAt: string; rulesetVersion: string; + }) { + return this.db.league.create({ data: { ...input, startsAt: new Date(input.startsAt), endsAt: new Date(input.endsAt) } }); + } + + async joinLeague(input: { + leagueId: string; ownerId: string; name: string; personaPrompt: string; tone: string; providerType?: ProviderType; modelName?: string; + }) { + const league = await this.db.league.findUnique({ where: { id: input.leagueId }, include: { agents: true } }); + if (!league) throw new DomainError('League not found', 404); + if (league.agents.length >= league.maxAgents) throw new DomainError('League is full'); + return this.db.agent.create({ data: { ...input, status: 'offline', seasonReserveBalance: 0 } }); + } + + async activateAgent(agentId: string) { + const agent = await this.db.agent.findUnique({ where: { id: agentId }, include: { league: true, wallet: true } }); + if (!agent || !agent.wallet) throw new DomainError('Agent or wallet missing', 404); + const required = Number(agent.league.buyInAmount) + Number(agent.league.weeklyStakeAmount) * agent.league.seasonWeeks; + if (Number(agent.wallet.availableBalance) < required) throw new DomainError('Agent cannot join unless prefunded'); + + return this.db.$transaction(async (tx) => { + const wallet = await tx.wallet.update({ + where: { id: agent.wallet!.id }, + data: { + availableBalance: Number(agent.wallet!.availableBalance) - required, + lockedBalance: Number(agent.wallet!.lockedBalance) + required, + }, + }); + const updatedAgent = await tx.agent.update({ where: { id: agentId }, data: { status: 'active', seasonReserveBalance: required } }); + await tx.ledgerEntry.createMany({ + data: [ + { walletId: wallet.id, amount: Number(agent.league.buyInAmount), direction: 'debit', entryType: 'buy_in_lock', referenceType: 'league', referenceId: agent.leagueId, description: 'Buy-in lock from available', leagueId: agent.leagueId, agentId }, + { walletId: wallet.id, amount: Number(agent.league.weeklyStakeAmount) * agent.league.seasonWeeks, direction: 'debit', entryType: 'weekly_lock', referenceType: 'league', referenceId: agent.leagueId, description: 'Weekly reserve lock from available', leagueId: agent.leagueId, agentId }, + { walletId: wallet.id, amount: required, direction: 'credit', entryType: 'adjustment', referenceType: 'league', referenceId: agent.leagueId, description: 'Internal lock bucket increase (available->locked)', leagueId: agent.leagueId, agentId }, + ], + }); + return updatedAgent; + }); + } +} diff --git a/src/services/ledgerService.ts b/src/services/ledgerService.ts new file mode 100644 index 0000000..d671ab4 --- /dev/null +++ b/src/services/ledgerService.ts @@ -0,0 +1,31 @@ +import type { PrismaClient, Direction, LedgerEntryType, ReferenceType } from '@prisma/client'; + +export class LedgerService { + constructor(private db: PrismaClient) {} + + async postEntry(input: { + walletId: string; + amount: number; + direction: Direction; + entryType: LedgerEntryType; + referenceType: ReferenceType; + referenceId?: string; + description: string; + leagueId?: string; + agentId?: string; + }) { + return this.db.ledgerEntry.create({ + data: { + walletId: input.walletId, + amount: input.amount, + direction: input.direction, + entryType: input.entryType, + referenceType: input.referenceType, + referenceId: input.referenceId, + description: input.description, + leagueId: input.leagueId, + agentId: input.agentId, + }, + }); + } +} diff --git a/src/services/matchupService.ts b/src/services/matchupService.ts new file mode 100644 index 0000000..12484b4 --- /dev/null +++ b/src/services/matchupService.ts @@ -0,0 +1,29 @@ +import type { PrismaClient } from '@prisma/client'; +import { DomainError } from '../utils/errors.js'; + +export class MatchupService { + constructor(private db: PrismaClient) {} + + async listMatchups(leagueId: string, week?: number) { + return this.db.matchup.findMany({ where: { leagueId, ...(week ? { weekNumber: week } : {}) } }); + } + + async finalizeMatchup(matchupId: string, input: { agentAScore: number; agentBScore: number; resultSource?: string; adminOverride?: boolean }) { + const matchup = await this.db.matchup.findUnique({ where: { id: matchupId } }); + if (!matchup) throw new DomainError('Matchup not found', 404); + if (matchup.status === 'settled') throw new DomainError('Settled matchup cannot be edited'); + if (matchup.status === 'finalized' && !input.adminOverride) throw new DomainError('Result cannot be edited after finalization without admin override'); + + const winnerAgentId = input.agentAScore >= input.agentBScore ? matchup.agentAId : matchup.agentBId; + return this.db.matchup.update({ + where: { id: matchupId }, + data: { + agentAScore: input.agentAScore, + agentBScore: input.agentBScore, + winnerAgentId, + status: 'finalized', + resultSource: input.resultSource ?? 'manual', + }, + }); + } +} diff --git a/src/services/messageService.ts b/src/services/messageService.ts new file mode 100644 index 0000000..1b904b1 --- /dev/null +++ b/src/services/messageService.ts @@ -0,0 +1,24 @@ +import type { PrismaClient } from '@prisma/client'; +import { DomainError } from '../utils/errors.js'; +import { ModerationService } from './moderationService.js'; + +export class MessageService { + private moderation = new ModerationService(); + constructor(private db: PrismaClient) {} + + async postMessage(input: { + leagueId: string; agentId: string; channelName: string; messageType: 'trash_talk'|'reaction'|'result_comment'|'announcement'; + content: string; visibility: 'public'|'owner_visible'|'system'; matchupId?: string; weekNumber?: number; + }) { + const policy = await this.db.agentPolicy.findUnique({ where: { agentId: input.agentId } }); + if (!policy || policy.status !== 'active') throw new DomainError('Agent policy missing', 400); + if (!policy.canPostPublicMessages && input.visibility === 'public') throw new DomainError('Public posting disabled', 403); + + const since = new Date(Date.now() - 60 * 60 * 1000); + const recentCount = await this.db.message.count({ where: { agentId: input.agentId, createdAt: { gte: since } } }); + if (recentCount >= policy.messageRateLimitPerHour) throw new DomainError('Message rate limit exceeded', 429); + + const moderationStatus = this.moderation.moderate(input.content); + return this.db.message.create({ data: { ...input, moderationStatus } }); + } +} diff --git a/src/services/moderationService.ts b/src/services/moderationService.ts new file mode 100644 index 0000000..a2c0bf5 --- /dev/null +++ b/src/services/moderationService.ts @@ -0,0 +1,25 @@ +import type { ModerationStatus } from '@prisma/client'; +import { env } from '../config/env.js'; + +const blockedPatterns = [ + /\bslur\b/i, + /\bhate\b/i, + /\bthreat\b/i, + /\bdoxx/i, + /\bkill\b/i, + /sexual|harass/i, + /violence|violent/i, +]; +const flaggedPatterns = [/idiot/i, /shut up/i, /loser/i, /spam/i]; + +export class ModerationService { + moderate(content: string): ModerationStatus { + if (blockedPatterns.some((p) => p.test(content))) return 'blocked'; + if (content.length > 280 || flaggedPatterns.some((p) => p.test(content))) return 'flagged'; + if (env.moderationProvider !== 'rules') { + // Stubbed provider-backed moderation hook for future external moderation API. + return 'approved'; + } + return 'approved'; + } +} diff --git a/src/services/settlementService.ts b/src/services/settlementService.ts new file mode 100644 index 0000000..9314a8f --- /dev/null +++ b/src/services/settlementService.ts @@ -0,0 +1,139 @@ +import type { PrismaClient } from '@prisma/client'; +import { DomainError } from '../utils/errors.js'; + +export class SettlementService { + constructor(private db: PrismaClient) {} + + async settleMatchup(matchupId: string, triggerSource = 'manual') { + return this.db.$transaction(async (tx) => { + const matchup = await tx.matchup.findUnique({ where: { id: matchupId }, include: { escrow: true, winner: { include: { wallet: true } }, settlement: true } }); + if (!matchup) throw new DomainError('Matchup not found', 404); + if (matchup.settlement?.status === 'confirmed' || matchup.status === 'settled') return matchup.settlement; + if (matchup.status === 'forfeit') throw new DomainError('Forfeit matchup cannot be settled as escrow payout'); + if (matchup.status !== 'finalized') throw new DomainError('Matchup must be finalized before settlement'); + if (!matchup.escrow || matchup.escrow.status !== 'funded') throw new DomainError('Escrow must be funded before settlement'); + if (!matchup.winner?.wallet) throw new DomainError('Winner wallet missing'); + + const idempotencyKey = `weekly:${matchup.id}`; + const existing = await tx.settlement.findUnique({ where: { idempotencyKey } }); + if (existing?.status === 'confirmed') return existing; + + const settlement = existing ?? await tx.settlement.create({ + data: { + leagueId: matchup.leagueId, + matchupId: matchup.id, + settlementType: 'weekly_matchup', + winnerAgentId: matchup.winnerAgentId, + fromWalletId: matchup.escrow.escrowWalletId, + toWalletId: matchup.winner.wallet.id, + amount: Number(matchup.escrow.totalLockedAmount), + status: 'submitted', + idempotencyKey, + triggerSource, + }, + }); + + await tx.wallet.update({ + where: { id: matchup.winner.wallet.id }, + data: { availableBalance: Number(matchup.winner.wallet.availableBalance) + Number(matchup.escrow.totalLockedAmount) }, + }); + await tx.wallet.update({ where: { id: matchup.escrow.escrowWalletId }, data: { lockedBalance: 0, availableBalance: 0 } }); + await tx.escrow.update({ where: { matchupId: matchup.id }, data: { status: 'released', releasedAt: new Date() } }); + + await tx.ledgerEntry.createMany({ + data: [ + { + walletId: matchup.winner.wallet.id, + leagueId: matchup.leagueId, + agentId: matchup.winnerAgentId!, + amount: Number(matchup.escrow.totalLockedAmount), + direction: 'credit', + entryType: 'escrow_release', + referenceType: 'settlement', + referenceId: settlement.id, + description: 'Weekly matchup escrow release (winner credit)', + }, + { + walletId: matchup.escrow.escrowWalletId, + leagueId: matchup.leagueId, + amount: Number(matchup.escrow.totalLockedAmount), + direction: 'debit', + entryType: 'escrow_release', + referenceType: 'settlement', + referenceId: settlement.id, + description: 'Weekly matchup escrow release (escrow debit)', + }, + ], + }); + + await tx.settlement.update({ where: { id: settlement.id }, data: { status: 'confirmed' } }); + await tx.matchup.update({ where: { id: matchup.id }, data: { status: 'settled', settlementId: settlement.id } }); + return settlement; + }); + } + + async runWeekly(leagueId: string, weekNumber: number) { + const matchups = await this.db.matchup.findMany({ where: { leagueId, weekNumber } }); + return Promise.all(matchups.map((m) => this.settleMatchup(m.id, 'weekly_job').catch(() => null))); + } + + async runSeason(leagueId: string) { + const league = await this.db.league.findUnique({ where: { id: leagueId } }); + if (!league) throw new DomainError('League not found', 404); + + const wins = await this.db.matchup.groupBy({ by: ['winnerAgentId'], where: { leagueId, status: { in: ['settled', 'forfeit'] } }, _count: true }); + const top = wins.filter((w) => w.winnerAgentId).sort((a, b) => b._count - a._count || String(a.winnerAgentId).localeCompare(String(b.winnerAgentId)))[0]; + if (!top?.winnerAgentId) throw new DomainError('No season winner determined yet'); + + const idempotencyKey = `season:${leagueId}:prize`; + const existing = await this.db.settlement.findUnique({ where: { idempotencyKey } }); + if (existing?.status === 'confirmed') return existing; + + const winnerWallet = await this.db.wallet.findUnique({ where: { agentId: top.winnerAgentId } }); + if (!winnerWallet) throw new DomainError('Winner wallet missing'); + + const prizePool = Number(league.buyInAmount) * Number(league.maxAgents); + const leagueVault = await this.db.wallet.upsert({ + where: { id: `league_vault_${leagueId}` }, + update: {}, + create: { + id: `league_vault_${leagueId}`, + walletType: 'league_vault', + leagueId, + assetSymbol: winnerWallet.assetSymbol, + availableBalance: prizePool, + lockedBalance: 0, + }, + }); + + return this.db.$transaction(async (tx) => { + const settlement = existing ?? await tx.settlement.create({ + data: { + leagueId, + settlementType: 'season_prize', + winnerAgentId: top.winnerAgentId!, + fromWalletId: leagueVault.id, + toWalletId: winnerWallet.id, + amount: prizePool, + status: 'submitted', + idempotencyKey, + triggerSource: 'season_job', + }, + }); + + await tx.wallet.update({ where: { id: leagueVault.id }, data: { availableBalance: Math.max(0, Number(leagueVault.availableBalance) - prizePool) } }); + await tx.wallet.update({ where: { id: winnerWallet.id }, data: { availableBalance: Number(winnerWallet.availableBalance) + prizePool } }); + + await tx.ledgerEntry.createMany({ + data: [ + { walletId: leagueVault.id, leagueId, amount: prizePool, direction: 'debit', entryType: 'prize_payout', referenceType: 'settlement', referenceId: settlement.id, description: 'Season prize vault debit' }, + { walletId: winnerWallet.id, leagueId, agentId: top.winnerAgentId!, amount: prizePool, direction: 'credit', entryType: 'prize_payout', referenceType: 'settlement', referenceId: settlement.id, description: 'Season prize winner credit' }, + ], + }); + + await tx.settlement.update({ where: { id: settlement.id }, data: { status: 'confirmed' } }); + await tx.league.update({ where: { id: leagueId }, data: { status: 'completed' } }); + return settlement; + }); + } +} diff --git a/src/services/walletService.ts b/src/services/walletService.ts new file mode 100644 index 0000000..750ea85 --- /dev/null +++ b/src/services/walletService.ts @@ -0,0 +1,37 @@ +import type { PrismaClient, WalletType } from '@prisma/client'; +import { DomainError } from '../utils/errors.js'; +import { LedgerService } from './ledgerService.js'; + +export class WalletService { + private ledger: LedgerService; + + constructor(private db: PrismaClient) { + this.ledger = new LedgerService(db); + } + + async createWallet(input: { walletType: WalletType; agentId?: string; leagueId?: string; assetSymbol: string }) { + return this.db.wallet.create({ data: { ...input } }); + } + + async fundWallet(walletId: string, amount: number, description = 'Manual funding') { + return this.db.$transaction(async (tx) => { + const wallet = await tx.wallet.findUnique({ where: { id: walletId } }); + if (!wallet) throw new DomainError('Wallet not found', 404); + const updated = await tx.wallet.update({ + where: { id: walletId }, + data: { availableBalance: Number(wallet.availableBalance) + amount }, + }); + await new LedgerService(tx as unknown as PrismaClient).postEntry({ + walletId, + amount, + direction: 'credit', + entryType: 'deposit', + referenceType: 'manual', + description, + leagueId: wallet.leagueId ?? undefined, + agentId: wallet.agentId ?? undefined, + }); + return updated; + }); + } +} diff --git a/src/simulation/simulator.ts b/src/simulation/simulator.ts new file mode 100644 index 0000000..cbd291e --- /dev/null +++ b/src/simulation/simulator.ts @@ -0,0 +1,62 @@ +import type { PrismaClient } from '@prisma/client'; +import { ProviderFactory } from '../providers/factory.js'; +import { MessageService } from '../services/messageService.js'; +import { weeklyEscrowJob } from '../jobs/weeklyEscrowJob.js'; +import { weeklyFinalizeJob } from '../jobs/weeklyFinalizeJob.js'; +import { weeklySettlementJob } from '../jobs/weeklySettlementJob.js'; +import { env } from '../config/env.js'; + +function deterministicScore(seed: number, week: number, index: number, offset = 0) { + const base = (seed * 31 + week * 17 + index * 13 + offset * 7) % 40; + return 70 + base; +} + +export class Simulator { + constructor(private db: PrismaClient) {} + + async runWeek(leagueId: string, weekNumber: number, deterministic = true) { + await weeklyEscrowJob(this.db, leagueId, weekNumber); + + if (deterministic) { + const matchups = await this.db.matchup.findMany({ where: { leagueId, weekNumber, status: 'escrow_funded' } }); + for (let i = 0; i < matchups.length; i += 1) { + await this.db.matchup.update({ + where: { id: matchups[i].id }, + data: { + agentAScore: deterministicScore(env.simulationSeed, weekNumber, i, 1), + agentBScore: deterministicScore(env.simulationSeed, weekNumber, i, 2), + winnerAgentId: deterministicScore(env.simulationSeed, weekNumber, i, 1) >= deterministicScore(env.simulationSeed, weekNumber, i, 2) ? matchups[i].agentAId : matchups[i].agentBId, + status: 'finalized', + resultSource: 'deterministic_simulation', + }, + }); + } + } else { + await weeklyFinalizeJob(this.db, leagueId, weekNumber); + } + + await weeklySettlementJob(this.db, leagueId, weekNumber); + + const messageService = new MessageService(this.db); + const agents = await this.db.agent.findMany({ where: { leagueId } }); + for (const agent of agents) { + const provider = ProviderFactory.create(agent.providerType); + const action = await provider.decideAction({ agent, weekNumber, leagueSummary: `Week ${weekNumber}`, recentMessages: [] }); + if (action === 'idle') continue; + const content = await provider.generateMessage({ agent, weekNumber, leagueSummary: `Week ${weekNumber}`, recentMessages: ['rival chatter rising'] }); + await messageService.postMessage({ + leagueId, + agentId: agent.id, + channelName: 'league-public', + messageType: 'trash_talk', + content, + visibility: 'public', + weekNumber, + }).catch(() => undefined); + } + } + + async runSeason(leagueId: string, seasonWeeks: number, deterministic = true) { + for (let week = 1; week <= seasonWeeks; week += 1) await this.runWeek(leagueId, week, deterministic); + } +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..b89b014 --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,5 @@ +export class DomainError extends Error { + constructor(message: string, public readonly statusCode = 400) { + super(message); + } +} diff --git a/src/utils/money.ts b/src/utils/money.ts new file mode 100644 index 0000000..8b41490 --- /dev/null +++ b/src/utils/money.ts @@ -0,0 +1,4 @@ +export const toCents = (v: number) => Math.round(v * 100); +export const fromCents = (v: number) => Number((v / 100).toFixed(2)); +export const add = (a: number, b: number) => fromCents(toCents(a) + toCents(b)); +export const sub = (a: number, b: number) => fromCents(toCents(a) - toCents(b)); diff --git a/tests/apiFlows.test.ts b/tests/apiFlows.test.ts new file mode 100644 index 0000000..e547b78 --- /dev/null +++ b/tests/apiFlows.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { buildApp } from '../src/app.js'; + +describe('core API route presence', () => { + it('registers demo-critical endpoints', async () => { + const app = buildApp(); + const checks = [ + { method: 'GET', url: '/leagues/demo/overview' }, + { method: 'GET', url: '/leagues/demo/standings' }, + { method: 'GET', url: '/leagues/demo/ledger' }, + { method: 'POST', url: '/messages/trigger-sample', payload: { leagueId: 'demo' } }, + { method: 'GET', url: '/agent-tools/a1/get_wallet_status' }, + ] as const; + + for (const c of checks) { + const res = await app.inject(c as any); + expect(res.statusCode).not.toBe(404); + } + }); +}); diff --git a/tests/apiValidation.test.ts b/tests/apiValidation.test.ts new file mode 100644 index 0000000..5c9fe7b --- /dev/null +++ b/tests/apiValidation.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest'; +import { buildApp } from '../src/app.js'; + +describe('api validation', () => { + it('rejects invalid league payload', async () => { + const app = buildApp(); + const res = await app.inject({ method: 'POST', url: '/leagues', payload: { name: 'x' } }); + expect(res.statusCode).toBe(500); + }); +}); diff --git a/tests/escrowService.test.ts b/tests/escrowService.test.ts new file mode 100644 index 0000000..2bed88c --- /dev/null +++ b/tests/escrowService.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, vi } from 'vitest'; +import { EscrowService } from '../src/services/escrowService.js'; + +describe('escrow lock idempotency + forfeit', () => { + it('returns existing funded escrow without double funding', async () => { + const tx: any = { + matchup: { findUnique: vi.fn().mockResolvedValue({ id: 'm1', status: 'escrow_funded', escrow: { id: 'e1', status: 'funded' } }) }, + }; + const db: any = { $transaction: (fn: any) => fn(tx) }; + const res = await new EscrowService(db).lockEscrow('m1'); + expect(res.id).toBe('e1'); + }); + + it('marks forfeit on reserve deficiency', async () => { + const tx: any = { + matchup: { + findUnique: vi.fn().mockResolvedValue({ + id: 'm2', leagueId: 'l1', status: 'scheduled', + agentAId: 'a1', agentBId: 'a2', + league: { weeklyStakeAmount: 1 }, + agentA: { wallet: { id: 'w1', lockedBalance: 0, assetSymbol: 'USDC' } }, + agentB: { wallet: { id: 'w2', lockedBalance: 1, assetSymbol: 'USDC' } }, + escrow: null, + }), + update: vi.fn().mockResolvedValue({}), + }, + message: { create: vi.fn().mockResolvedValue({}) }, + }; + const db: any = { $transaction: (fn: any) => fn(tx) }; + await expect(new EscrowService(db).lockEscrow('m2')).rejects.toThrow(/forfeited/i); + expect(tx.matchup.update).toHaveBeenCalled(); + }); +}); diff --git a/tests/frontendSmoke.test.ts b/tests/frontendSmoke.test.ts new file mode 100644 index 0000000..852547d --- /dev/null +++ b/tests/frontendSmoke.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; + +describe('frontend main views present', () => { + it('has demo pages and admin controls', () => { + const required = [ + 'frontend/app/page.tsx', + 'frontend/app/standings/page.tsx', + 'frontend/app/matchups/page.tsx', + 'frontend/app/agents/page.tsx', + 'frontend/app/wallets/page.tsx', + 'frontend/app/messages/page.tsx', + 'frontend/app/admin/page.tsx', + ]; + for (const file of required) expect(fs.existsSync(file)).toBe(true); + }); +}); diff --git a/tests/jobs.test.ts b/tests/jobs.test.ts new file mode 100644 index 0000000..035067e --- /dev/null +++ b/tests/jobs.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect, vi } from 'vitest'; +import { weeklyEscrowJob } from '../src/jobs/weeklyEscrowJob.js'; + +describe('double-run job protection', () => { + it('returns summary and tolerates repeated escrow lock attempts', async () => { + const db: any = { + matchup: { findMany: vi.fn().mockResolvedValue([{ id: 'm1' }]) }, + $transaction: (fn: any) => fn({ + matchup: { findUnique: vi.fn().mockResolvedValue({ id: 'm1', status: 'escrow_funded', escrow: { id: 'e1', status: 'funded' } }) }, + }), + }; + const result = await weeklyEscrowJob(db, 'l1', 1); + expect(result.attempted).toBe(1); + expect(result.succeeded).toBe(1); + }); +}); diff --git a/tests/leagueService.test.ts b/tests/leagueService.test.ts new file mode 100644 index 0000000..597a7df --- /dev/null +++ b/tests/leagueService.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect, vi } from 'vitest'; +import { LeagueService } from '../src/services/leagueService.js'; + +describe('onboarding/prefund activation', () => { + it('rejects activation if wallet not prefunded', async () => { + const db: any = { + agent: { findUnique: vi.fn().mockResolvedValue({ id: 'a1', league: { buyInAmount: 15, weeklyStakeAmount: 1, seasonWeeks: 6 }, wallet: { id: 'w1', availableBalance: 10, lockedBalance: 0 } }) }, + }; + const svc = new LeagueService(db); + await expect(svc.activateAgent('a1')).rejects.toThrow(/prefunded/i); + }); +}); diff --git a/tests/matchupService.test.ts b/tests/matchupService.test.ts new file mode 100644 index 0000000..c9ffb68 --- /dev/null +++ b/tests/matchupService.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect, vi } from 'vitest'; +import { MatchupService } from '../src/services/matchupService.js'; + +describe('result finalization', () => { + it('prevents edits after finalization without override', async () => { + const db: any = { matchup: { findUnique: vi.fn().mockResolvedValue({ id: 'm1', status: 'finalized' }) } }; + const svc = new MatchupService(db); + await expect(svc.finalizeMatchup('m1', { agentAScore: 1, agentBScore: 2 })).rejects.toThrow(/admin override/i); + }); +}); diff --git a/tests/messageService.test.ts b/tests/messageService.test.ts new file mode 100644 index 0000000..0ec482f --- /dev/null +++ b/tests/messageService.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect, vi } from 'vitest'; +import { MessageService } from '../src/services/messageService.js'; + +describe('message rate limits', () => { + it('blocks when agent exceeds policy hourly rate', async () => { + const db: any = { + agentPolicy: { findUnique: vi.fn().mockResolvedValue({ status: 'active', canPostPublicMessages: true, messageRateLimitPerHour: 1 }) }, + message: { + count: vi.fn().mockResolvedValue(1), + create: vi.fn(), + }, + }; + const svc = new MessageService(db); + await expect(svc.postMessage({ leagueId: 'l1', agentId: 'a1', channelName: 'league-public', messageType: 'trash_talk', content: 'hello', visibility: 'public' })).rejects.toThrow(/rate limit/i); + }); +}); diff --git a/tests/moderation.test.ts b/tests/moderation.test.ts new file mode 100644 index 0000000..6d66282 --- /dev/null +++ b/tests/moderation.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest'; +import { ModerationService } from '../src/services/moderationService.js'; + +describe('moderation', () => { + it('blocks disallowed content', () => { + expect(new ModerationService().moderate('this is hate speech')).toBe('blocked'); + }); + + it('flags borderline content', () => { + expect(new ModerationService().moderate('you are an idiot')).toBe('flagged'); + }); +}); diff --git a/tests/providerFactory.test.ts b/tests/providerFactory.test.ts new file mode 100644 index 0000000..b549e7d --- /dev/null +++ b/tests/providerFactory.test.ts @@ -0,0 +1,9 @@ +import { describe, it, expect } from 'vitest'; +import { ProviderFactory } from '../src/providers/factory.js'; + +describe('provider abstraction', () => { + it('returns scripted provider by default', () => { + const provider = ProviderFactory.create(); + expect(provider.constructor.name).toBe('ScriptedAgentProvider'); + }); +}); diff --git a/tests/providerFallback.test.ts b/tests/providerFallback.test.ts new file mode 100644 index 0000000..3a44f6b --- /dev/null +++ b/tests/providerFallback.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { OpenAIProvider } from '../src/providers/openaiProvider.js'; +import { AnthropicProvider } from '../src/providers/anthropicProvider.js'; + +describe('provider fallback behavior', () => { + it('openai provider falls back without key', async () => { + const out = await new OpenAIProvider().generateMessage({ agent: { name: 'A1' } as any, leagueSummary: 'x', recentMessages: [] }); + expect(out).toContain('fallback'); + }); + + it('anthropic provider falls back without key', async () => { + const out = await new AnthropicProvider().generateMessage({ agent: { name: 'A2' } as any, leagueSummary: 'x', recentMessages: [] }); + expect(out).toContain('fallback'); + }); +}); diff --git a/tests/scriptedProvider.test.ts b/tests/scriptedProvider.test.ts new file mode 100644 index 0000000..00efe36 --- /dev/null +++ b/tests/scriptedProvider.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { ScriptedAgentProvider } from '../src/providers/scriptedProvider.js'; + +describe('scripted provider', () => { + it('generates playful message from context', async () => { + const provider = new ScriptedAgentProvider(); + const msg = await provider.generateMessage({ + agent: { id: 'a1', name: 'Captain Claw' } as any, + weekNumber: 2, + leagueSummary: 'Week 2 clash', + recentMessages: [], + }); + expect(msg).toContain('Captain Claw'); + expect(msg).toContain('Week 2'); + }); +}); diff --git a/tests/settlementService.test.ts b/tests/settlementService.test.ts new file mode 100644 index 0000000..3496812 --- /dev/null +++ b/tests/settlementService.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect, vi } from 'vitest'; +import { SettlementService } from '../src/services/settlementService.js'; + +describe('settlement idempotency', () => { + it('returns confirmed settlement when already settled', async () => { + const confirmed = { id: 's1', status: 'confirmed' }; + const tx: any = { + matchup: { findUnique: vi.fn().mockResolvedValue({ id: 'm1', status: 'settled', settlement: confirmed }) }, + }; + const db: any = { $transaction: (fn: any) => fn(tx) }; + const res = await new SettlementService(db).settleMatchup('m1'); + expect(res).toEqual(confirmed); + }); +}); diff --git a/tests/simulator.test.ts b/tests/simulator.test.ts new file mode 100644 index 0000000..35b1a1d --- /dev/null +++ b/tests/simulator.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Simulator } from '../src/simulation/simulator.js'; + +describe('simulation season run', () => { + it('runs all season weeks', async () => { + const db: any = { + matchup: { findMany: vi.fn().mockResolvedValue([]) }, + agent: { findMany: vi.fn().mockResolvedValue([]) }, + }; + const sim = new Simulator(db); + await sim.runSeason('l1', 2); + expect(db.matchup.findMany).toHaveBeenCalled(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9ed7f53 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src", "prisma/seed.ts", "tests"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..4ac6027 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + }, +});