diff --git a/.env.example b/.env.example index abd24da..76dff5a 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ POSTGRES_DB=parcel_society DATABASE_URL="postgresql://parcel:parcel_password@localhost:5432/parcel_society?schema=public" APP_SECRET="replace-with-a-long-random-secret" NEXTAUTH_SECRET="replace-with-a-long-random-secret" -ADMIN_EMAIL="admin@example.org" -ADMIN_PASSWORD="replace-with-a-development-password" +ADMIN_EMAIL="admin@example.com" +ADMIN_PASSWORD="changeme" WEB_PORT=3000 NODE_ENV="development" diff --git a/README.md b/README.md index 67018fd..c7101e3 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,8 @@ Copy `.env.example` to `.env` and update values as needed. ```bash DATABASE_URL="postgresql://parcel:parcel_password@localhost:5432/parcel_society?schema=public" APP_SECRET="replace-with-a-long-random-secret" -ADMIN_EMAIL="admin@example.org" -ADMIN_PASSWORD="replace-with-a-development-password" +ADMIN_EMAIL="admin@example.com" +ADMIN_PASSWORD="changeme" NODE_ENV="development" ``` @@ -100,6 +100,26 @@ NODE_ENV="development" - `pnpm db:migrate` - run local Prisma migrations - `pnpm db:studio` - open Prisma Studio - `pnpm seed` - seed one admin and the four demo treatment servers +- `pnpm seed:demo` - create the full local demo experiment with maps, anonymous players, synthetic decisions, and resolved rounds + +## Demo Mode + +Create a complete local demo experiment after installing dependencies, generating the Prisma client, and applying migrations: + +```bash +pnpm seed:demo +``` + +The demo seed creates one admin account, four active 2x2 treatment servers, a 10x10 map for each server, 20 anonymous demo participants per server, parcel assignments, a 7-round active season, synthetic decisions for rounds 1-3, resolved round states, contracts, treasury transactions, events, and dashboard-ready analytics. Running it again replaces the existing demo servers with a fresh deterministic demo dataset. + +Demo login: + +```text +admin@example.com +changeme +``` + +Warning: Demo credentials are only for local development. Set `ADMIN_EMAIL` and `ADMIN_PASSWORD` for any shared or deployed environment. ## Database Migrations @@ -135,6 +155,12 @@ Parcel Society uses Prisma with PostgreSQL. The schema lives at `packages/db/pri pnpm seed ``` +6. Or create the complete demo mode dataset with active servers, players, synthetic decisions, and three resolved rounds: + + ```bash + pnpm seed:demo + ``` + For non-development deployments, run Prisma migrations during release using the same schema path, for example `pnpm --filter @parcel-society/db prisma migrate deploy --schema prisma/schema.prisma`. ## Roadmap diff --git a/apps/web/app/admin/login/page.tsx b/apps/web/app/admin/login/page.tsx index 5c93b65..73f0bb1 100644 --- a/apps/web/app/admin/login/page.tsx +++ b/apps/web/app/admin/login/page.tsx @@ -1,4 +1,59 @@ "use client"; import { useState } from "react"; import { AdminPageHeader, Card } from "../_components/ui"; -export default function AdminLoginPage() { const [email,setEmail]=useState(""); const [password,setPassword]=useState(""); const [saved,setSaved]=useState(false); return <>
{e.preventDefault(); window.localStorage.setItem("parcel_admin_basic", btoa(`${email}:${password}`)); setSaved(true);}} className="max-w-md space-y-4">{saved?

Credentials saved in this browser.

:null}
; } + +export default function AdminLoginPage() { + const [email, setEmail] = useState("admin@example.com"); + const [password, setPassword] = useState("changeme"); + const [saved, setSaved] = useState(false); + return ( + <> + + +
{ + event.preventDefault(); + window.localStorage.setItem( + "parcel_admin_basic", + btoa(`${email}:${password}`), + ); + setSaved(true); + }} + className="max-w-md space-y-4" + > + + +

+ Demo credentials are only for local development. +

+ + {saved ? ( +

+ Credentials saved in this browser. +

+ ) : null} +
+
+ + ); +} diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx index df8de06..f43ef55 100644 --- a/apps/web/app/admin/page.tsx +++ b/apps/web/app/admin/page.tsx @@ -1,36 +1,171 @@ import Link from "next/link"; import { prisma } from "@parcel-society/db"; +import { AdminActions } from "./_components/AdminActions"; +import { BarMetricChart, PieMetricChart } from "./_components/AdminCharts"; import { AdminPageHeader, Card, StatCard, ButtonLink } from "./_components/ui"; import { formatDate, formatNumber } from "./_components/format"; +import { getAnalyticsOverview } from "../../lib/services/adminAnalytics"; export default async function AdminPage() { - const [servers, totalPlayers, activePlayers, exitedPlayers, totalDecisions, recentEvents] = await Promise.all([ + const [ + servers, + totalPlayers, + activePlayers, + exitedPlayers, + totalDecisions, + recentEvents, + actionMix, + analyticsOverview, + ] = await Promise.all([ prisma.server.groupBy({ by: ["status"], _count: { _all: true } }), prisma.player.count(), prisma.player.count({ where: { exited: false } }), prisma.player.count({ where: { exited: true } }), prisma.decision.count(), - prisma.serverEvent.findMany({ take: 8, orderBy: { createdAt: "desc" }, include: { server: { select: { id: true, name: true } } } }), + prisma.serverEvent.findMany({ + take: 8, + orderBy: { createdAt: "desc" }, + include: { server: { select: { id: true, name: true } } }, + }), + prisma.decision.groupBy({ by: ["actionType"], _count: { _all: true } }), + getAnalyticsOverview(), ]); - const count = (status?: string) => status ? servers.find((row) => row.status === status)?._count._all ?? 0 : servers.reduce((total, row) => total + row._count._all, 0); + const count = (status?: string) => + status + ? (servers.find((row) => row.status === status)?._count._all ?? 0) + : servers.reduce((total, row) => total + row._count._all, 0); + const overviewChart = analyticsOverview.servers.map((server) => ({ + round: server.name.replace("Demo ", ""), + exitRate: Number(((server.latest?.exitRate ?? 0) * 100).toFixed(1)), + investmentShare: Number( + ((server.latest?.productiveInvestmentShare ?? 0) * 100).toFixed(1), + ), + publicShare: Number( + ((server.latest?.publicContributionShare ?? 0) * 100).toFixed(1), + ), + })); + const actionMixChart = actionMix.map((row) => ({ + name: row.actionType.replaceAll("_", " "), + value: row._count._all, + })); + const headerActions = ( + <> + {process.env.NODE_ENV === "development" ? ( + + ) : null} + Create server + + ); return ( <> - Create server} /> +
- - - + + + - + +
+
+ +

Demo outcomes by server

+

+ Latest available exit, investment, and public-good contribution + rates. +

+ {overviewChart.length > 0 ? ( + + ) : ( +

+ Seed demo data to populate this chart. +

+ )} +
+ +

Decision mix

+

+ Synthetic and participant decisions grouped by action type. +

+ {actionMixChart.length > 0 ? ( + + ) : ( +

+ No decisions have been recorded yet. +

+ )} +
-

Recent server events

View servers
+
+

Recent server events

+ + View servers + +
- {recentEvents.map((event) =>

{event.eventType} round {event.roundNumber}

{event.server.name}

{formatDate(event.createdAt)}

)} - {recentEvents.length === 0 ?

No events have been recorded yet.

: null} + {recentEvents.map((event) => ( +
+
+

+ {event.eventType}{" "} + + round {event.roundNumber} + +

+ + {event.server.name} + +
+

+ {formatDate(event.createdAt)} +

+
+ ))} + {recentEvents.length === 0 ? ( +

+ No events have been recorded yet. +

+ ) : null}
diff --git a/apps/web/app/api/admin/demo/route.ts b/apps/web/app/api/admin/demo/route.ts new file mode 100644 index 0000000..ba294dc --- /dev/null +++ b/apps/web/app/api/admin/demo/route.ts @@ -0,0 +1,33 @@ +import { prisma, seedDemo } from "@parcel-society/db"; +import { recordAdminAction } from "../../../../lib/api/audit"; +import { requireAdminAuth } from "../../../../lib/api/auth"; +import { + apiOk, + handleApiError, + ApiException, +} from "../../../../lib/api/responses"; + +export async function POST(request: Request) { + try { + if (process.env.NODE_ENV === "production") { + throw new ApiException( + 403, + "DEMO_SEED_DISABLED", + "Demo experiment creation is disabled in production.", + ); + } + + const auth = await requireAdminAuth(request); + const result = await seedDemo(prisma); + await recordAdminAction({ + auth, + action: "CREATE_DEMO_EXPERIMENT", + entityType: "demo", + entityId: "demo-seed", + after: result, + }); + return apiOk({ demo: result }); + } catch (error) { + return handleApiError(error, { route: "POST /api/admin/demo" }); + } +} diff --git a/apps/web/lib/api/auth.ts b/apps/web/lib/api/auth.ts index 46273bc..e528865 100644 --- a/apps/web/lib/api/auth.ts +++ b/apps/web/lib/api/auth.ts @@ -20,26 +20,34 @@ export const getParticipantAuth = async (): Promise => { const existingUserId = cookieStore.get(PARTICIPANT_COOKIE)?.value; if (existingUserId) { - const user = await prisma.user.findUnique({ where: { id: existingUserId } }); + const user = await prisma.user.findUnique({ + where: { id: existingUserId }, + }); if (user) { return { user }; } } - const user = await prisma.user.create({ data: { role: UserRole.PARTICIPANT } }); + const user = await prisma.user.create({ + data: { role: UserRole.PARTICIPANT }, + }); return { user, setCookie: { name: PARTICIPANT_COOKIE, value: user.id }, }; }; -const parseBasicAuth = (request: Request): { email: string; password: string } | null => { +const parseBasicAuth = ( + request: Request, +): { email: string; password: string } | null => { const header = request.headers.get("authorization"); if (!header?.startsWith("Basic ")) { return null; } - const decoded = Buffer.from(header.slice("Basic ".length), "base64").toString("utf8"); + const decoded = Buffer.from(header.slice("Basic ".length), "base64").toString( + "utf8", + ); const separator = decoded.indexOf(":"); if (separator === -1) { return null; @@ -51,14 +59,24 @@ const parseBasicAuth = (request: Request): { email: string; password: string } | }; }; -export const requireAdminAuth = async (request: Request): Promise => { +export const requireAdminAuth = async ( + request: Request, +): Promise => { rateLimit({ request, key: "admin-login", limit: 20, windowMs: 60_000 }); const credentials = parseBasicAuth(request); - const adminEmail = process.env.ADMIN_EMAIL; - const adminPassword = process.env.ADMIN_PASSWORD; + const adminEmail = + process.env.ADMIN_EMAIL ?? + (process.env.NODE_ENV === "production" ? undefined : "admin@example.com"); + const adminPassword = + process.env.ADMIN_PASSWORD ?? + (process.env.NODE_ENV === "production" ? undefined : "changeme"); if (!adminEmail || !adminPassword) { - throw new ApiException(500, "ADMIN_AUTH_NOT_CONFIGURED", "Admin credentials are not configured."); + throw new ApiException( + 500, + "ADMIN_AUTH_NOT_CONFIGURED", + "Admin credentials are not configured.", + ); } if ( @@ -66,7 +84,11 @@ export const requireAdminAuth = async (request: Request): Promise = credentials.email !== adminEmail || credentials.password !== adminPassword ) { - throw new ApiException(401, "UNAUTHORIZED", "Admin credentials are required."); + throw new ApiException( + 401, + "UNAUTHORIZED", + "Admin credentials are required.", + ); } const user = await prisma.user.upsert({ @@ -87,27 +109,43 @@ export const requireAdminAuth = async (request: Request): Promise = return { user }; }; -export const requireSuperAdminAuth = async (request: Request): Promise => { +export const requireSuperAdminAuth = async ( + request: Request, +): Promise => { const auth = await requireAdminAuth(request); if (auth.user.role !== UserRole.SUPER_ADMIN) { - throw new ApiException(403, "SUPER_ADMIN_REQUIRED", "Super admin privileges are required."); + throw new ApiException( + 403, + "SUPER_ADMIN_REQUIRED", + "Super admin privileges are required.", + ); } return auth; }; -export const assertParticipantOnServer = async (userId: string, serverId: string) => { +export const assertParticipantOnServer = async ( + userId: string, + serverId: string, +) => { const player = await prisma.player.findUnique({ where: { userId_serverId: { userId, serverId } }, }); if (!player) { - throw new ApiException(403, "FORBIDDEN", "You are not a participant on this server."); + throw new ApiException( + 403, + "FORBIDDEN", + "You are not a participant on this server.", + ); } return player; }; -export const applyAuthCookie = (response: T, auth: AuthContext): T => { +export const applyAuthCookie = ( + response: T, + auth: AuthContext, +): T => { if (auth.setCookie) { response.headers.append( "Set-Cookie", diff --git a/package.json b/package.json index 404c1aa..28c5d57 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "db:generate": "pnpm --filter @parcel-society/db db:generate", "db:migrate": "pnpm --filter @parcel-society/db db:migrate", "db:studio": "pnpm --filter @parcel-society/db db:studio", - "seed": "pnpm --filter @parcel-society/db seed" + "seed": "pnpm --filter @parcel-society/db seed", + "seed:demo": "pnpm --filter @parcel-society/db seed:demo" }, "devDependencies": { "@eslint/js": "^9.26.0", diff --git a/packages/db/package.json b/packages/db/package.json index 655913f..0333692 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -18,10 +18,12 @@ "db:migrate": "prisma migrate dev --schema prisma/schema.prisma", "db:studio": "prisma studio --schema prisma/schema.prisma", "seed": "tsx scripts/seed.ts", - "db:deploy": "prisma db push --schema prisma/schema.prisma" + "db:deploy": "prisma db push --schema prisma/schema.prisma", + "seed:demo": "tsx scripts/seed-demo.ts" }, "dependencies": { - "@prisma/client": "^6.7.0" + "@prisma/client": "^6.7.0", + "@parcel-society/engine": "workspace:*" }, "devDependencies": { "prisma": "^6.7.0", diff --git a/packages/db/scripts/seed-demo.ts b/packages/db/scripts/seed-demo.ts new file mode 100644 index 0000000..8de391d --- /dev/null +++ b/packages/db/scripts/seed-demo.ts @@ -0,0 +1,18 @@ +import { PrismaClient } from "@prisma/client"; +import { seedDemo } from "../src/demoSeed"; + +const prisma = new PrismaClient(); + +seedDemo(prisma) + .then(async (result) => { + console.log(`Demo seed complete for ${result.serverCount} servers.`); + console.log( + `Demo admin login: ${result.adminEmail} / ${result.adminPassword}`, + ); + await prisma.$disconnect(); + }) + .catch(async (error: unknown) => { + console.error(error); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/packages/db/scripts/seed.ts b/packages/db/scripts/seed.ts index eca3f62..4fdf7a1 100644 --- a/packages/db/scripts/seed.ts +++ b/packages/db/scripts/seed.ts @@ -8,33 +8,37 @@ import { const prisma = new PrismaClient(); -const adminEmail = process.env.ADMIN_EMAIL ?? "admin@example.org"; +const adminEmail = process.env.ADMIN_EMAIL ?? "admin@example.com"; const demoServers = [ { name: "Demo: Low Inequality + Stable", - description: "Demo server for low initial spatial inequality and stable institutions.", + description: + "Demo server for low initial spatial inequality and stable institutions.", inequalityCondition: InequalityCondition.LOW, uncertaintyCondition: UncertaintyCondition.STABLE, randomSeed: "demo-low-stable-v1", }, { name: "Demo: Low Inequality + Uncertain", - description: "Demo server for low initial spatial inequality and uncertain institutions.", + description: + "Demo server for low initial spatial inequality and uncertain institutions.", inequalityCondition: InequalityCondition.LOW, uncertaintyCondition: UncertaintyCondition.UNCERTAIN, randomSeed: "demo-low-uncertain-v1", }, { name: "Demo: High Inequality + Stable", - description: "Demo server for high initial spatial inequality and stable institutions.", + description: + "Demo server for high initial spatial inequality and stable institutions.", inequalityCondition: InequalityCondition.HIGH, uncertaintyCondition: UncertaintyCondition.STABLE, randomSeed: "demo-high-stable-v1", }, { name: "Demo: High Inequality + Uncertain", - description: "Demo server for high initial spatial inequality and uncertain institutions.", + description: + "Demo server for high initial spatial inequality and uncertain institutions.", inequalityCondition: InequalityCondition.HIGH, uncertaintyCondition: UncertaintyCondition.UNCERTAIN, randomSeed: "demo-high-uncertain-v1", diff --git a/packages/db/src/demoSeed.ts b/packages/db/src/demoSeed.ts new file mode 100644 index 0000000..c664d6a --- /dev/null +++ b/packages/db/src/demoSeed.ts @@ -0,0 +1,621 @@ +import { + ActionType, + ContractType, + InequalityCondition, + Prisma, + PrismaClient, + RoundStatus, + SeasonStatus, + ServerEventType, + ServerStatus, + TreasuryTransactionType, + UncertaintyCondition, + UserRole, +} from "@prisma/client"; +import { + generateMap, + resolveRound, + type Decision as EngineDecision, + type EngineConfig, + type Parcel as EngineParcel, + type PlayerState, + type ServerState, +} from "@parcel-society/engine"; + +const demoServerSpecs = [ + { + name: "Demo Low Inequality Stable", + description: + "Demo mode server for low initial inequality and stable institutions.", + inequalityCondition: InequalityCondition.LOW, + uncertaintyCondition: UncertaintyCondition.STABLE, + randomSeed: "demo-low-inequality-stable-v2", + }, + { + name: "Demo Low Inequality Uncertain", + description: + "Demo mode server for low initial inequality and uncertain institutions.", + inequalityCondition: InequalityCondition.LOW, + uncertaintyCondition: UncertaintyCondition.UNCERTAIN, + randomSeed: "demo-low-inequality-uncertain-v2", + }, + { + name: "Demo High Inequality Stable", + description: + "Demo mode server for high initial inequality and stable institutions.", + inequalityCondition: InequalityCondition.HIGH, + uncertaintyCondition: UncertaintyCondition.STABLE, + randomSeed: "demo-high-inequality-stable-v2", + }, + { + name: "Demo High Inequality Uncertain", + description: + "Demo mode server for high initial inequality and uncertain institutions.", + inequalityCondition: InequalityCondition.HIGH, + uncertaintyCondition: UncertaintyCondition.UNCERTAIN, + randomSeed: "demo-high-inequality-uncertain-v2", + }, +] as const; + +type DemoSeedOptions = { + adminEmail?: string; + adminPassword?: string; +}; + +type Decimalish = Prisma.Decimal | number; + +const toNumber = (value: Decimalish): number => + typeof value === "number" ? value : value.toNumber(); + +const demoConfig = (spec: (typeof demoServerSpecs)[number]): EngineConfig => ({ + seed: spec.randomSeed, + inequality: spec.inequalityCondition, + uncertainty: spec.uncertaintyCondition, + mapWidth: 10, + mapHeight: 10, + actionPointsPerRound: 3, + production: { + A: 10, + betaQ: 1, + betaK: 0.35, + minShockMultiplier: 0.45, + maxShockMultiplier: 1, + }, + taxRate: 0.15, + formalContractFee: 2, + informalContractFee: 0, + informalDefaultRisk: 0.25, + formalDefaultRisk: 0.05, + shockProbability: + spec.uncertaintyCondition === UncertaintyCondition.UNCERTAIN ? 0.35 : 0.1, + startingWealth: 100, + investmentUnitCost: 10, + safeAssetReturn: 0.03, + publicGoodMultiplier: 1.5, + lobbyingCost: 5, +}); + +const eventType = (type: string): ServerEventType => { + if (type === "TAX_CHANGE") return ServerEventType.TAX_CHANGE; + if (type === "FORMAL_CONTRACT_FEE_CHANGE") + return ServerEventType.FORMAL_CONTRACT_FEE_CHANGE; + if (type === "SHOCK_PROBABILITY_CHANGE") + return ServerEventType.SHOCK_PROBABILITY_CHANGE; + return ServerEventType.RESOURCE_SHOCK; +}; + +const transactionType = (reason: string): TreasuryTransactionType => { + if (reason === "TAX") return TreasuryTransactionType.TAX; + if (reason === "PUBLIC_CONTRIBUTION") + return TreasuryTransactionType.CONTRIBUTION; + if (reason.endsWith("CONTRACT_FEE")) return TreasuryTransactionType.FEE; + return TreasuryTransactionType.ADJUSTMENT; +}; + +const enginePlayer = ( + player: { + id: string; + wealth: Decimalish; + productiveCapital: Decimalish; + safeAsset: Decimalish; + exited: boolean; + parcelId: string; + }, + actionPoints: number, +): PlayerState => ({ + id: player.id, + wealth: toNumber(player.wealth), + productiveCapital: toNumber(player.productiveCapital), + safeAssets: toNumber(player.safeAsset), + exited: player.exited, + parcelIds: [player.parcelId], + actionPointsRemaining: actionPoints, + contributedPublic: 0, + spentOnProductiveInvestment: 0, + spentOnSafeAssets: 0, + spentOnLobbying: 0, +}); + +const engineParcel = (parcel: { + id: string; + x: number; + y: number; + soil: Decimalish; + water: Decimalish; + marketAccess: Decimalish; + risk: Decimalish; + quality: Decimalish; + ownerId: string | null; +}): EngineParcel => ({ + id: parcel.id, + x: parcel.x, + y: parcel.y, + soil: toNumber(parcel.soil), + water: toNumber(parcel.water), + marketAccess: toNumber(parcel.marketAccess), + risk: toNumber(parcel.risk), + quality: toNumber(parcel.quality), + ownerId: parcel.ownerId ?? undefined, +}); + +const decisionsForRound = ( + players: Array<{ id: string; parcelId: string }>, + roundNumber: number, +): EngineDecision[] => + players.flatMap((player, index) => { + const counterpart = players[(index + roundNumber) % players.length]; + const base: EngineDecision[] = [ + { playerId: player.id, type: "PRODUCE", parcelId: player.parcelId }, + ]; + + if (roundNumber === 1) { + base.push( + index % 2 === 0 + ? { + playerId: player.id, + type: "PRODUCTIVE_INVESTMENT", + amount: 12 + (index % 3) * 3, + } + : { + playerId: player.id, + type: "SAFE_ASSET", + amount: 8 + (index % 4) * 2, + }, + ); + base.push( + index % 5 === 0 + ? { playerId: player.id, type: "PUBLIC_CONTRIBUTION", amount: 5 } + : { + playerId: player.id, + type: index % 3 === 0 ? "FORMAL_CONTRACT" : "INFORMAL_CONTRACT", + amount: 4 + (index % 4), + counterpartyId: counterpart.id, + }, + ); + return base; + } + + if (roundNumber === 2) { + base.push( + index % 4 === 0 + ? { playerId: player.id, type: "LOBBYING", amount: 5 } + : { + playerId: player.id, + type: "PRODUCTIVE_INVESTMENT", + amount: 10 + (index % 2) * 5, + }, + ); + base.push( + index % 3 === 0 + ? { playerId: player.id, type: "PUBLIC_CONTRIBUTION", amount: 6 } + : { + playerId: player.id, + type: "SAFE_ASSET", + amount: 6 + (index % 3) * 2, + }, + ); + return base; + } + + base.push( + index % 2 === 0 + ? { + playerId: player.id, + type: "FORMAL_CONTRACT", + amount: 5 + (index % 5), + counterpartyId: counterpart.id, + } + : { + playerId: player.id, + type: "PUBLIC_CONTRIBUTION", + amount: 4 + (index % 4), + }, + ); + if (index === 3 || index === 14) { + base.push({ playerId: player.id, type: "EXIT" }); + } else { + base.push( + index % 5 === 0 + ? { playerId: player.id, type: "LOBBYING", amount: 5 } + : { playerId: player.id, type: "SAFE_ASSET", amount: 5 }, + ); + } + return base; + }); + +const resolveSeedRound = async ({ + tx, + serverId, + seed, + uncertainty, + config, + roundId, + roundNumber, +}: { + tx: Prisma.TransactionClient; + serverId: string; + seed: string; + uncertainty: UncertaintyCondition; + config: EngineConfig; + roundId: string; + roundNumber: number; +}) => { + const [server, players, parcels, decisions] = await Promise.all([ + tx.server.findUniqueOrThrow({ where: { id: serverId } }), + tx.player.findMany({ where: { serverId }, orderBy: { createdAt: "asc" } }), + tx.parcel.findMany({ + where: { serverId }, + orderBy: [{ y: "asc" }, { x: "asc" }], + }), + tx.decision.findMany({ + where: { serverId, roundNumber }, + orderBy: { createdAt: "asc" }, + }), + ]); + + const result = resolveRound({ + server: { + round: roundNumber - 1, + taxRate: Number(config.taxRate), + formalContractFee: Number(config.formalContractFee), + shockProbability: Number(config.shockProbability), + treasury: toNumber(server.treasury), + uncertainty, + events: [], + } satisfies ServerState, + players: players.map((player) => + enginePlayer(player, config.actionPointsPerRound), + ), + parcels: parcels.map(engineParcel), + decisions: decisions.map((decision) => ({ + playerId: decision.playerId, + type: decision.actionType, + amount: toNumber(decision.amount), + parcelId: (decision.payload as { parcelId?: string }).parcelId, + counterpartyId: decision.targetPlayerId ?? undefined, + })), + config, + seed, + }); + + for (const player of result.players) { + await tx.player.update({ + where: { id: player.id }, + data: { + wealth: player.wealth, + productiveCapital: player.productiveCapital, + safeAsset: player.safeAssets, + exited: player.exited, + roundExited: player.exited ? roundNumber : undefined, + }, + }); + await tx.playerRoundState.create({ + data: { + playerId: player.id, + serverId, + roundNumber, + wealth: player.wealth, + productiveCapital: player.productiveCapital, + safeAsset: player.safeAssets, + reputation: 0, + exited: player.exited, + state: { roundSummary: result.roundSummary } as Prisma.InputJsonValue, + }, + }); + } + + if (result.contracts.length > 0) { + await tx.contract.createMany({ + data: result.contracts.map((contract) => ({ + senderId: contract.fromPlayerId, + receiverId: contract.toPlayerId, + serverId, + roundNumber, + contractType: + contract.type === "FORMAL" + ? ContractType.FORMAL + : ContractType.INFORMAL, + value: contract.amount, + fee: contract.fee, + fulfilled: contract.fulfilled ?? null, + defaulted: + contract.fulfilled === undefined ? null : !contract.fulfilled, + resolvedAt: new Date(), + })), + }); + } + + if (result.serverEvents.length > 0) { + await tx.serverEvent.createMany({ + data: result.serverEvents.map((event) => ({ + serverId, + roundNumber, + eventType: eventType(event.type), + value: event as unknown as Prisma.InputJsonValue, + })), + }); + } + + if (result.treasuryTransactions.length > 0) { + await tx.treasuryTransaction.createMany({ + data: result.treasuryTransactions.map((transaction) => ({ + serverId, + playerId: transaction.playerId, + roundNumber, + type: transactionType(transaction.reason), + amount: transaction.amount, + description: transaction.reason, + })), + }); + } + + await tx.round.update({ + where: { id: roundId }, + data: { status: RoundStatus.RESOLVED }, + }); + await tx.server.update({ + where: { id: serverId }, + data: { + currentRound: roundNumber, + treasury: result.server.treasury, + config: { + demo: true, + demoResolvedRounds: 3, + mapWidth: 10, + mapHeight: 10, + taxRate: result.server.taxRate, + formalContractFee: result.server.formalContractFee, + shockProbability: result.server.shockProbability, + actionPointsPerRound: config.actionPointsPerRound, + startingWealth: config.startingWealth, + productionA: config.production.A, + productionBetaQ: config.production.betaQ, + productionBetaK: config.production.betaK, + minShockMultiplier: config.production.minShockMultiplier, + maxShockMultiplier: config.production.maxShockMultiplier, + informalContractFee: config.informalContractFee, + informalDefaultRisk: config.informalDefaultRisk, + formalDefaultRisk: config.formalDefaultRisk, + safeAssetReturn: config.safeAssetReturn, + publicGoodMultiplier: config.publicGoodMultiplier, + investmentUnitCost: config.investmentUnitCost, + lobbyingCost: config.lobbyingCost, + }, + }, + }); +}; + +export async function seedDemo( + prisma: PrismaClient, + options: DemoSeedOptions = {}, +) { + const adminEmail = + options.adminEmail ?? process.env.ADMIN_EMAIL ?? "admin@example.com"; + const adminPassword = + options.adminPassword ?? process.env.ADMIN_PASSWORD ?? "changeme"; + + const admin = await prisma.user.upsert({ + where: { email: adminEmail }, + update: { role: UserRole.SUPER_ADMIN }, + create: { + email: adminEmail, + role: UserRole.SUPER_ADMIN, + }, + }); + + await prisma.adminUser.upsert({ + where: { userId: admin.id }, + update: { email: adminEmail, name: "Demo Admin" }, + create: { userId: admin.id, email: adminEmail, name: "Demo Admin" }, + }); + + const servers = []; + for (const spec of demoServerSpecs) { + const server = await prisma.$transaction( + async (tx) => { + const existing = await tx.server.findUnique({ + where: { name: spec.name }, + include: { players: { select: { userId: true } } }, + }); + if (existing) { + const demoUserIds = existing.players.map((player) => player.userId); + await tx.server.delete({ where: { id: existing.id } }); + if (demoUserIds.length > 0) { + await tx.user.deleteMany({ where: { id: { in: demoUserIds } } }); + } + } + + const config = demoConfig(spec); + const createdServer = await tx.server.create({ + data: { + name: spec.name, + description: spec.description, + status: ServerStatus.ACTIVE, + inequalityCondition: spec.inequalityCondition, + uncertaintyCondition: spec.uncertaintyCondition, + maxPlayers: 20, + currentRound: 0, + seasonLength: 7, + treasury: 0, + randomSeed: spec.randomSeed, + config: { + demo: true, + mapWidth: 10, + mapHeight: 10, + actionPointsPerRound: config.actionPointsPerRound, + startingWealth: config.startingWealth, + taxRate: config.taxRate, + formalContractFee: config.formalContractFee, + informalContractFee: config.informalContractFee, + formalDefaultRisk: config.formalDefaultRisk, + informalDefaultRisk: config.informalDefaultRisk, + shockProbability: config.shockProbability, + safeAssetReturn: config.safeAssetReturn, + publicGoodMultiplier: config.publicGoodMultiplier, + investmentUnitCost: config.investmentUnitCost, + lobbyingCost: config.lobbyingCost, + productionA: config.production.A, + productionBetaQ: config.production.betaQ, + productionBetaK: config.production.betaK, + minShockMultiplier: config.production.minShockMultiplier, + maxShockMultiplier: config.production.maxShockMultiplier, + }, + }, + }); + + const generatedParcels = generateMap({ + seed: spec.randomSeed, + inequality: spec.inequalityCondition, + width: 10, + height: 10, + }); + await tx.parcel.createMany({ + data: generatedParcels.map((parcel) => ({ + serverId: createdServer.id, + x: parcel.x, + y: parcel.y, + soil: parcel.soil, + water: parcel.water, + marketAccess: parcel.marketAccess, + risk: parcel.risk, + quality: parcel.quality, + })), + }); + + const parcels = await tx.parcel.findMany({ + where: { serverId: createdServer.id }, + orderBy: [ + { + quality: + spec.inequalityCondition === InequalityCondition.HIGH + ? "desc" + : "asc", + }, + { y: "asc" }, + { x: "asc" }, + ], + take: 20, + }); + + for (let index = 0; index < 20; index += 1) { + const user = await tx.user.create({ + data: { + role: UserRole.PARTICIPANT, + }, + }); + const player = await tx.player.create({ + data: { + userId: user.id, + serverId: createdServer.id, + parcelId: parcels[index].id, + wealth: config.startingWealth, + }, + }); + await tx.parcel.update({ + where: { id: parcels[index].id }, + data: { ownerId: player.id }, + }); + } + + const now = new Date(); + const season = await tx.season.create({ + data: { + serverId: createdServer.id, + startsAt: now, + endsAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000), + status: SeasonStatus.ACTIVE, + }, + }); + + let activeRound = await tx.round.create({ + data: { + serverId: createdServer.id, + seasonId: season.id, + roundNumber: 1, + status: RoundStatus.ACTIVE, + startsAt: now, + endsAt: new Date(now.getTime() + 24 * 60 * 60 * 1000), + }, + }); + + for (let roundNumber = 1; roundNumber <= 3; roundNumber += 1) { + const activePlayers = await tx.player.findMany({ + where: { serverId: createdServer.id, exited: false }, + select: { id: true, parcelId: true }, + orderBy: { createdAt: "asc" }, + }); + const decisions = decisionsForRound(activePlayers, roundNumber); + await tx.decision.createMany({ + data: decisions.map((decision) => ({ + playerId: decision.playerId, + serverId: createdServer.id, + roundNumber, + actionType: decision.type as ActionType, + amount: decision.amount ?? 0, + targetPlayerId: decision.counterpartyId ?? null, + payload: decision.parcelId ? { parcelId: decision.parcelId } : {}, + })), + }); + await resolveSeedRound({ + tx, + serverId: createdServer.id, + seed: spec.randomSeed, + uncertainty: spec.uncertaintyCondition, + config, + roundId: activeRound.id, + roundNumber, + }); + activeRound = await tx.round.create({ + data: { + serverId: createdServer.id, + seasonId: season.id, + roundNumber: roundNumber + 1, + status: RoundStatus.ACTIVE, + startsAt: new Date( + now.getTime() + roundNumber * 24 * 60 * 60 * 1000, + ), + endsAt: new Date( + now.getTime() + (roundNumber + 1) * 24 * 60 * 60 * 1000, + ), + }, + }); + } + + await tx.server.update({ + where: { id: createdServer.id }, + data: { status: ServerStatus.ACTIVE, currentRound: 3 }, + }); + return createdServer; + }, + { timeout: 30_000 }, + ); + servers.push(server); + } + + return { + adminEmail, + adminPassword, + serverCount: servers.length, + serverNames: servers.map((server) => server.name), + }; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 2dd17cb..220a605 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -13,3 +13,4 @@ export { UncertaintyCondition, UserRole, } from "@prisma/client"; +export { seedDemo } from "./demoSeed";