diff --git a/apps/web/app/api/admin/analytics/overview/route.ts b/apps/web/app/api/admin/analytics/overview/route.ts new file mode 100644 index 0000000..70ec727 --- /dev/null +++ b/apps/web/app/api/admin/analytics/overview/route.ts @@ -0,0 +1,25 @@ +import { prisma, ServerStatus } from "@parcel-society/db"; +import { requireAdminAuth } from "../../../../../lib/api/auth"; +import { apiOk, handleApiError } from "../../../../../lib/api/responses"; + +export async function GET(request: Request) { + try { + await requireAdminAuth(request); + const [serversByStatus, playerCount, decisionCount, contractCount, treasuryAggregate] = await Promise.all([ + prisma.server.groupBy({ by: ["status"], _count: { _all: true } }), + prisma.player.count(), + prisma.decision.count(), + prisma.contract.count(), + prisma.server.aggregate({ _sum: { treasury: true }, where: { status: { not: ServerStatus.ARCHIVED } } }), + ]); + return apiOk({ + serversByStatus, + playerCount, + decisionCount, + contractCount, + activeTreasury: treasuryAggregate._sum.treasury ?? 0, + }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/app/api/admin/servers/[serverId]/archive/route.ts b/apps/web/app/api/admin/servers/[serverId]/archive/route.ts new file mode 100644 index 0000000..b3ad966 --- /dev/null +++ b/apps/web/app/api/admin/servers/[serverId]/archive/route.ts @@ -0,0 +1,15 @@ +import { prisma, ServerStatus } from "@parcel-society/db"; +import { requireAdminAuth } from "../../../../../../lib/api/auth"; +import { apiOk, handleApiError } from "../../../../../../lib/api/responses"; +import { serverIdParamsSchema } from "../../../../../../lib/api/schemas"; + +export async function POST(request: Request, context: { params: Promise<{ serverId: string }> }) { + try { + await requireAdminAuth(request); + const { serverId } = serverIdParamsSchema.parse(await context.params); + const server = await prisma.server.update({ where: { id: serverId }, data: { status: ServerStatus.ARCHIVED } }); + return apiOk({ server }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/app/api/admin/servers/[serverId]/export/route.ts b/apps/web/app/api/admin/servers/[serverId]/export/route.ts new file mode 100644 index 0000000..77722a3 --- /dev/null +++ b/apps/web/app/api/admin/servers/[serverId]/export/route.ts @@ -0,0 +1,52 @@ +import { prisma } from "@parcel-society/db"; +import { requireAdminAuth } from "../../../../../../lib/api/auth"; +import { handleApiError } from "../../../../../../lib/api/responses"; +import { serverIdParamsSchema } from "../../../../../../lib/api/schemas"; + +const csvEscape = (value: unknown): string => { + if (value === null || value === undefined) return ""; + const text = value instanceof Date ? value.toISOString() : typeof value === "object" ? JSON.stringify(value) : String(value); + return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +}; + +const tableToCsv = (rows: object[]): string => { + const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))]; + return [ + headers.join(","), + ...rows.map((row) => + headers + .map((header) => csvEscape((row as Record)[header])) + .join(","), + ), + ].join("\n"); +}; + +export async function GET(request: Request, context: { params: Promise<{ serverId: string }> }) { + try { + await requireAdminAuth(request); + const { serverId } = serverIdParamsSchema.parse(await context.params); + const [players, parcels, decisions, contracts, serverEvents, treasuryTransactions, roundOutcomes] = await Promise.all([ + prisma.player.findMany({ where: { serverId }, orderBy: { createdAt: "asc" } }), + prisma.parcel.findMany({ where: { serverId }, orderBy: [{ y: "asc" }, { x: "asc" }] }), + prisma.decision.findMany({ where: { serverId }, orderBy: [{ roundNumber: "asc" }, { createdAt: "asc" }] }), + prisma.contract.findMany({ where: { serverId }, orderBy: [{ roundNumber: "asc" }, { createdAt: "asc" }] }), + prisma.serverEvent.findMany({ where: { serverId }, orderBy: [{ roundNumber: "asc" }, { createdAt: "asc" }] }), + prisma.treasuryTransaction.findMany({ where: { serverId }, orderBy: [{ roundNumber: "asc" }, { createdAt: "asc" }] }), + prisma.playerRoundState.findMany({ where: { serverId }, orderBy: [{ roundNumber: "asc" }, { playerId: "asc" }] }), + ]); + + const files = { + players: tableToCsv(players), + parcels: tableToCsv(parcels), + decisions: tableToCsv(decisions), + contracts: tableToCsv(contracts), + server_events: tableToCsv(serverEvents), + treasury_transactions: tableToCsv(treasuryTransactions), + round_outcomes: tableToCsv(roundOutcomes), + }; + + return Response.json({ ok: true, data: { files } }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/app/api/admin/servers/[serverId]/generate-map/route.ts b/apps/web/app/api/admin/servers/[serverId]/generate-map/route.ts new file mode 100644 index 0000000..e8ad6c9 --- /dev/null +++ b/apps/web/app/api/admin/servers/[serverId]/generate-map/route.ts @@ -0,0 +1,16 @@ +import { requireAdminAuth } from "../../../../../../lib/api/auth"; +import { apiOk, handleApiError } from "../../../../../../lib/api/responses"; +import { generateMapSchema, serverIdParamsSchema } from "../../../../../../lib/api/schemas"; +import { createServerMap } from "../../../../../../lib/services/game"; + +export async function POST(request: Request, context: { params: Promise<{ serverId: string }> }) { + try { + await requireAdminAuth(request); + const { serverId } = serverIdParamsSchema.parse(await context.params); + const body = generateMapSchema.parse(await request.json().catch(() => ({}))); + const result = await createServerMap({ serverId, width: body.width, height: body.height }); + return apiOk(result); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/app/api/admin/servers/[serverId]/resolve-round/route.ts b/apps/web/app/api/admin/servers/[serverId]/resolve-round/route.ts new file mode 100644 index 0000000..12f269f --- /dev/null +++ b/apps/web/app/api/admin/servers/[serverId]/resolve-round/route.ts @@ -0,0 +1,14 @@ +import { requireAdminAuth } from "../../../../../../lib/api/auth"; +import { apiOk, handleApiError } from "../../../../../../lib/api/responses"; +import { serverIdParamsSchema } from "../../../../../../lib/api/schemas"; +import { resolveActiveRound } from "../../../../../../lib/services/game"; + +export async function POST(request: Request, context: { params: Promise<{ serverId: string }> }) { + try { + await requireAdminAuth(request); + const { serverId } = serverIdParamsSchema.parse(await context.params); + return apiOk({ summary: await resolveActiveRound(serverId) }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/app/api/admin/servers/[serverId]/route.ts b/apps/web/app/api/admin/servers/[serverId]/route.ts new file mode 100644 index 0000000..f7f4773 --- /dev/null +++ b/apps/web/app/api/admin/servers/[serverId]/route.ts @@ -0,0 +1,37 @@ +import { prisma } from "@parcel-society/db"; +import { requireAdminAuth } from "../../../../../lib/api/auth"; +import { ApiException, apiOk, handleApiError } from "../../../../../lib/api/responses"; +import { serverIdParamsSchema, updateServerSchema } from "../../../../../lib/api/schemas"; + +export async function GET(request: Request, context: { params: Promise<{ serverId: string }> }) { + try { + await requireAdminAuth(request); + const { serverId } = serverIdParamsSchema.parse(await context.params); + const server = await prisma.server.findUnique({ + where: { id: serverId }, + include: { + seasons: true, + rounds: true, + parcels: true, + players: true, + _count: { select: { decisions: true, contracts: true, events: true } }, + }, + }); + if (!server) throw new ApiException(404, "SERVER_NOT_FOUND", "Server was not found."); + return apiOk({ server }); + } catch (error) { + return handleApiError(error); + } +} + +export async function PATCH(request: Request, context: { params: Promise<{ serverId: string }> }) { + try { + await requireAdminAuth(request); + const { serverId } = serverIdParamsSchema.parse(await context.params); + const body = updateServerSchema.parse(await request.json()); + const server = await prisma.server.update({ where: { id: serverId }, data: body }); + return apiOk({ server }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/app/api/admin/servers/[serverId]/start/route.ts b/apps/web/app/api/admin/servers/[serverId]/start/route.ts new file mode 100644 index 0000000..f12c2ba --- /dev/null +++ b/apps/web/app/api/admin/servers/[serverId]/start/route.ts @@ -0,0 +1,14 @@ +import { requireAdminAuth } from "../../../../../../lib/api/auth"; +import { apiOk, handleApiError } from "../../../../../../lib/api/responses"; +import { serverIdParamsSchema } from "../../../../../../lib/api/schemas"; +import { startServer } from "../../../../../../lib/services/game"; + +export async function POST(request: Request, context: { params: Promise<{ serverId: string }> }) { + try { + await requireAdminAuth(request); + const { serverId } = serverIdParamsSchema.parse(await context.params); + return apiOk(await startServer(serverId)); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/app/api/admin/servers/route.ts b/apps/web/app/api/admin/servers/route.ts new file mode 100644 index 0000000..cdcb624 --- /dev/null +++ b/apps/web/app/api/admin/servers/route.ts @@ -0,0 +1,35 @@ +import { randomUUID } from "node:crypto"; +import { prisma, ServerStatus } from "@parcel-society/db"; +import { requireAdminAuth } from "../../../../lib/api/auth"; +import { apiOk, handleApiError } from "../../../../lib/api/responses"; +import { createServerSchema } from "../../../../lib/api/schemas"; + +export async function GET(request: Request) { + try { + await requireAdminAuth(request); + const servers = await prisma.server.findMany({ + include: { _count: { select: { players: true, parcels: true, decisions: true } } }, + orderBy: { createdAt: "desc" }, + }); + return apiOk({ servers }); + } catch (error) { + return handleApiError(error); + } +} + +export async function POST(request: Request) { + try { + await requireAdminAuth(request); + const body = createServerSchema.parse(await request.json()); + const server = await prisma.server.create({ + data: { + ...body, + randomSeed: body.randomSeed ?? randomUUID(), + status: ServerStatus.DRAFT, + }, + }); + return apiOk({ server }, { status: 201 }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/app/api/join-server/route.ts b/apps/web/app/api/join-server/route.ts new file mode 100644 index 0000000..2861c6e --- /dev/null +++ b/apps/web/app/api/join-server/route.ts @@ -0,0 +1,15 @@ +import { joinServerSchema } from "../../../lib/api/schemas"; +import { apiOk, handleApiError } from "../../../lib/api/responses"; +import { applyAuthCookie, getParticipantAuth } from "../../../lib/api/auth"; +import { joinWaitingServer } from "../../../lib/services/game"; + +export async function POST(request: Request) { + try { + const auth = await getParticipantAuth(); + const body = joinServerSchema.parse(await request.json()); + const player = await joinWaitingServer({ userId: auth.user.id, serverId: body.serverId }); + return applyAuthCookie(apiOk({ user: auth.user, player }), auth); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/app/api/me/route.ts b/apps/web/app/api/me/route.ts new file mode 100644 index 0000000..46a6442 --- /dev/null +++ b/apps/web/app/api/me/route.ts @@ -0,0 +1,16 @@ +import { prisma } from "@parcel-society/db"; +import { applyAuthCookie, getParticipantAuth } from "../../../lib/api/auth"; +import { apiOk, handleApiError } from "../../../lib/api/responses"; + +export async function GET() { + try { + const auth = await getParticipantAuth(); + const players = await prisma.player.findMany({ + where: { userId: auth.user.id }, + include: { server: true, parcel: true }, + }); + return applyAuthCookie(apiOk({ user: auth.user, players }), auth); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/app/api/servers/[serverId]/decisions/route.ts b/apps/web/app/api/servers/[serverId]/decisions/route.ts new file mode 100644 index 0000000..4a06955 --- /dev/null +++ b/apps/web/app/api/servers/[serverId]/decisions/route.ts @@ -0,0 +1,24 @@ +import { ActionType } from "@parcel-society/db"; +import { applyAuthCookie, getParticipantAuth } from "../../../../../lib/api/auth"; +import { apiOk, handleApiError } from "../../../../../lib/api/responses"; +import { serverIdParamsSchema, submitDecisionsSchema } from "../../../../../lib/api/schemas"; +import { submitPlayerDecisions } from "../../../../../lib/services/game"; + +export async function POST(request: Request, context: { params: Promise<{ serverId: string }> }) { + try { + const { serverId } = serverIdParamsSchema.parse(await context.params); + const auth = await getParticipantAuth(); + const body = submitDecisionsSchema.parse(await request.json()); + const result = await submitPlayerDecisions({ + userId: auth.user.id, + serverId, + decisions: body.decisions.map((decision) => ({ + ...decision, + type: decision.type as ActionType, + })), + }); + return applyAuthCookie(apiOk(result), auth); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/app/api/servers/[serverId]/exit/route.ts b/apps/web/app/api/servers/[serverId]/exit/route.ts new file mode 100644 index 0000000..3aa1534 --- /dev/null +++ b/apps/web/app/api/servers/[serverId]/exit/route.ts @@ -0,0 +1,20 @@ +import { prisma } from "@parcel-society/db"; +import { assertParticipantOnServer, applyAuthCookie, getParticipantAuth } from "../../../../../lib/api/auth"; +import { apiOk, handleApiError } from "../../../../../lib/api/responses"; +import { serverIdParamsSchema } from "../../../../../lib/api/schemas"; + +export async function POST(_request: Request, context: { params: Promise<{ serverId: string }> }) { + try { + const { serverId } = serverIdParamsSchema.parse(await context.params); + const auth = await getParticipantAuth(); + const player = await assertParticipantOnServer(auth.user.id, serverId); + const server = await prisma.server.findUnique({ where: { id: serverId } }); + const updated = await prisma.player.update({ + where: { id: player.id }, + data: { exited: true, roundExited: server?.currentRound ?? player.roundExited }, + }); + return applyAuthCookie(apiOk({ player: updated }), auth); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/app/api/servers/[serverId]/round-summary/route.ts b/apps/web/app/api/servers/[serverId]/round-summary/route.ts new file mode 100644 index 0000000..2d96462 --- /dev/null +++ b/apps/web/app/api/servers/[serverId]/round-summary/route.ts @@ -0,0 +1,19 @@ +import { prisma } from "@parcel-society/db"; +import { assertParticipantOnServer, applyAuthCookie, getParticipantAuth } from "../../../../../lib/api/auth"; +import { apiOk, handleApiError } from "../../../../../lib/api/responses"; +import { serverIdParamsSchema } from "../../../../../lib/api/schemas"; + +export async function GET(_request: Request, context: { params: Promise<{ serverId: string }> }) { + try { + const { serverId } = serverIdParamsSchema.parse(await context.params); + const auth = await getParticipantAuth(); + const player = await assertParticipantOnServer(auth.user.id, serverId); + const latest = await prisma.playerRoundState.findFirst({ + where: { serverId, playerId: player.id }, + orderBy: { roundNumber: "desc" }, + }); + return applyAuthCookie(apiOk({ summary: latest?.state ?? null }), auth); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/app/api/servers/[serverId]/state/route.ts b/apps/web/app/api/servers/[serverId]/state/route.ts new file mode 100644 index 0000000..ca3309e --- /dev/null +++ b/apps/web/app/api/servers/[serverId]/state/route.ts @@ -0,0 +1,23 @@ +import { prisma } from "@parcel-society/db"; +import { assertParticipantOnServer, applyAuthCookie, getParticipantAuth } from "../../../../../lib/api/auth"; +import { handleApiError, apiOk } from "../../../../../lib/api/responses"; +import { serverIdParamsSchema } from "../../../../../lib/api/schemas"; + +export async function GET(_request: Request, context: { params: Promise<{ serverId: string }> }) { + try { + const { serverId } = serverIdParamsSchema.parse(await context.params); + const auth = await getParticipantAuth(); + const player = await assertParticipantOnServer(auth.user.id, serverId); + const server = await prisma.server.findUnique({ + where: { id: serverId }, + include: { + parcels: true, + rounds: { orderBy: { roundNumber: "desc" }, take: 1 }, + players: { select: { id: true, parcelId: true, wealth: true, exited: true } }, + }, + }); + return applyAuthCookie(apiOk({ server, player }), auth); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/app/api/servers/available/route.ts b/apps/web/app/api/servers/available/route.ts new file mode 100644 index 0000000..169c308 --- /dev/null +++ b/apps/web/app/api/servers/available/route.ts @@ -0,0 +1,17 @@ +import { prisma, ServerStatus } from "@parcel-society/db"; +import { applyAuthCookie, getParticipantAuth } from "../../../../lib/api/auth"; +import { apiOk, handleApiError } from "../../../../lib/api/responses"; + +export async function GET() { + try { + const auth = await getParticipantAuth(); + const servers = await prisma.server.findMany({ + where: { status: ServerStatus.WAITING }, + include: { _count: { select: { players: true, parcels: true } } }, + orderBy: { createdAt: "desc" }, + }); + return applyAuthCookie(apiOk({ servers }), auth); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/lib/api/auth.ts b/apps/web/lib/api/auth.ts new file mode 100644 index 0000000..3bb8a53 --- /dev/null +++ b/apps/web/lib/api/auth.ts @@ -0,0 +1,108 @@ +import { cookies } from "next/headers"; +import { prisma, UserRole } from "@parcel-society/db"; +import { ApiException } from "./responses"; + +const PARTICIPANT_COOKIE = "parcel_society_user_id"; + +export type AuthContext = { + user: { + id: string; + anonymousId: string; + email: string | null; + role: UserRole; + }; + setCookie?: { name: string; value: string }; +}; + +export const getParticipantAuth = async (): Promise => { + const cookieStore = await cookies(); + const existingUserId = cookieStore.get(PARTICIPANT_COOKIE)?.value; + + if (existingUserId) { + const user = await prisma.user.findUnique({ where: { id: existingUserId } }); + if (user) { + return { user }; + } + } + + 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 header = request.headers.get("authorization"); + if (!header?.startsWith("Basic ")) { + return null; + } + + const decoded = Buffer.from(header.slice("Basic ".length), "base64").toString("utf8"); + const separator = decoded.indexOf(":"); + if (separator === -1) { + return null; + } + + return { + email: decoded.slice(0, separator), + password: decoded.slice(separator + 1), + }; +}; + +export const requireAdminAuth = async (request: Request): Promise => { + const credentials = parseBasicAuth(request); + const adminEmail = process.env.ADMIN_EMAIL; + const adminPassword = process.env.ADMIN_PASSWORD; + + if (!adminEmail || !adminPassword) { + throw new ApiException(500, "ADMIN_AUTH_NOT_CONFIGURED", "Admin credentials are not configured."); + } + + if ( + !credentials || + credentials.email !== adminEmail || + credentials.password !== adminPassword + ) { + throw new ApiException(401, "UNAUTHORIZED", "Admin credentials are required."); + } + + const user = 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: user.id }, + update: { email: adminEmail }, + create: { userId: user.id, email: adminEmail, name: "Environment Admin" }, + }); + + return { user }; +}; + +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."); + } + + return player; +}; + +export const applyAuthCookie = (response: T, auth: AuthContext): T => { + if (auth.setCookie) { + response.headers.append( + "Set-Cookie", + `${auth.setCookie.name}=${auth.setCookie.value}; Path=/; HttpOnly; SameSite=Lax`, + ); + } + return response; +}; diff --git a/apps/web/lib/api/responses.ts b/apps/web/lib/api/responses.ts new file mode 100644 index 0000000..9c8a8bc --- /dev/null +++ b/apps/web/lib/api/responses.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { ZodError } from "zod"; + +export type ApiError = { + code: string; + message: string; + details?: unknown; +}; + +export type ApiResponse = + | { ok: true; data: T } + | { ok: false; error: ApiError }; + +export class ApiException extends Error { + readonly status: number; + readonly code: string; + readonly details?: unknown; + + constructor(status: number, code: string, message: string, details?: unknown) { + super(message); + this.status = status; + this.code = code; + this.details = details; + } +} + +export const apiOk = (data: T, init?: ResponseInit): NextResponse> => + NextResponse.json({ ok: true, data }, init); + +export const apiError = ( + status: number, + code: string, + message: string, + details?: unknown, +): NextResponse> => + NextResponse.json({ ok: false, error: { code, message, details } }, { status }); + +export const handleApiError = (error: unknown): NextResponse> => { + if (error instanceof ApiException) { + return apiError(error.status, error.code, error.message, error.details); + } + + if (error instanceof ZodError) { + return apiError(400, "VALIDATION_ERROR", "Request validation failed.", error.flatten()); + } + + console.error(error); + return apiError(500, "INTERNAL_SERVER_ERROR", "An unexpected error occurred."); +}; diff --git a/apps/web/lib/api/schemas.ts b/apps/web/lib/api/schemas.ts new file mode 100644 index 0000000..f80e78e --- /dev/null +++ b/apps/web/lib/api/schemas.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +export const serverIdParamsSchema = z.object({ serverId: z.string().min(1) }); + +export const joinServerSchema = z.object({ + serverId: z.string().min(1), +}); + +export const decisionActionSchema = z.object({ + type: z.enum([ + "PRODUCE", + "PRODUCTIVE_INVESTMENT", + "SAFE_ASSET", + "PUBLIC_CONTRIBUTION", + "INFORMAL_CONTRACT", + "FORMAL_CONTRACT", + "LOBBYING", + "EXIT", + ]), + amount: z.number().finite().nonnegative().optional(), + parcelId: z.string().min(1).optional(), + counterpartyId: z.string().min(1).optional(), + payload: z.record(z.unknown()).default({}), +}); + +export const submitDecisionsSchema = z.object({ + decisions: z.array(decisionActionSchema).min(1), +}); + +export const createServerSchema = z.object({ + name: z.string().min(1), + description: z.string().optional(), + inequalityCondition: z.enum(["LOW", "HIGH"]), + uncertaintyCondition: z.enum(["STABLE", "UNCERTAIN"]), + maxPlayers: z.number().int().positive().max(500).default(20), + seasonLength: z.number().int().positive().max(52).default(7), + randomSeed: z.string().min(1).optional(), + config: z.record(z.unknown()).default({}), +}); + +export const updateServerSchema = createServerSchema.partial().extend({ + status: z.enum(["DRAFT", "WAITING", "ACTIVE", "COMPLETED", "ARCHIVED"]).optional(), + treasury: z.number().finite().nonnegative().optional(), + currentRound: z.number().int().nonnegative().optional(), +}); + +export const generateMapSchema = z.object({ + width: z.number().int().positive().max(100).default(10), + height: z.number().int().positive().max(100).default(10), +}); diff --git a/apps/web/lib/services/game.ts b/apps/web/lib/services/game.ts new file mode 100644 index 0000000..212a681 --- /dev/null +++ b/apps/web/lib/services/game.ts @@ -0,0 +1,524 @@ +import { + ActionType, + ContractType, + InequalityCondition, + prisma, + RoundStatus, + SeasonStatus, + ServerEventType, + ServerStatus, + TreasuryTransactionType, + UncertaintyCondition, + type Prisma, +} from "@parcel-society/db"; +import { + decisionCost, + generateMap, + resolveRound, + validateDecisions, + type Decision as EngineDecision, + type EngineConfig, + type Parcel as EngineParcel, + type PlayerState, + type ServerState, +} from "@parcel-society/engine"; +import { ApiException } from "../api/responses"; + +type Decimalish = { toNumber(): number } | number; + +const toNumber = (value: Decimalish): number => + typeof value === "number" ? value : value.toNumber(); + +type EnginePlayerRecord = { + id: string; + wealth: Decimalish; + productiveCapital: Decimalish; + safeAsset: Decimalish; + exited: boolean; + parcelId: string; +}; + +type EngineParcelRecord = { + id: string; + x: number; + y: number; + soil: Decimalish; + water: Decimalish; + marketAccess: Decimalish; + risk: Decimalish; + quality: Decimalish; + ownerId: string | null; +}; + +type EngineDecisionRecord = { + playerId: string; + actionType: ActionType; + amount: Decimalish; + payload: Prisma.JsonValue; + targetPlayerId: string | null; +}; + +const jsonObject = (value: Prisma.JsonValue): Record => + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + +export const defaultEngineConfig = (server: { + randomSeed: string; + inequalityCondition: InequalityCondition; + uncertaintyCondition: UncertaintyCondition; + config: Prisma.JsonValue; +}): EngineConfig => { + const overrides = jsonObject(server.config); + return { + seed: server.randomSeed, + inequality: server.inequalityCondition, + uncertainty: server.uncertaintyCondition, + mapWidth: Number(overrides.mapWidth ?? 10), + mapHeight: Number(overrides.mapHeight ?? 10), + actionPointsPerRound: Number(overrides.actionPointsPerRound ?? 3), + production: { + A: Number(overrides.productionA ?? 10), + betaQ: Number(overrides.productionBetaQ ?? 1), + betaK: Number(overrides.productionBetaK ?? 0.35), + minShockMultiplier: Number(overrides.minShockMultiplier ?? 0.4), + maxShockMultiplier: Number(overrides.maxShockMultiplier ?? 1), + }, + taxRate: Number(overrides.taxRate ?? 0.15), + formalContractFee: Number(overrides.formalContractFee ?? 2), + informalContractFee: Number(overrides.informalContractFee ?? 0), + informalDefaultRisk: Number(overrides.informalDefaultRisk ?? 0.25), + formalDefaultRisk: Number(overrides.formalDefaultRisk ?? 0.05), + shockProbability: Number( + overrides.shockProbability ?? + (server.uncertaintyCondition === UncertaintyCondition.UNCERTAIN ? 0.35 : 0.1), + ), + startingWealth: Number(overrides.startingWealth ?? 100), + investmentUnitCost: Number(overrides.investmentUnitCost ?? 10), + safeAssetReturn: Number(overrides.safeAssetReturn ?? 0.03), + publicGoodMultiplier: Number(overrides.publicGoodMultiplier ?? 1.5), + lobbyingCost: Number(overrides.lobbyingCost ?? 5), + }; +}; + +export const joinWaitingServer = async ({ + userId, + serverId, +}: { + userId: string; + serverId: string; +}) => + prisma.$transaction(async (tx) => { + const server = await tx.server.findUnique({ + where: { id: serverId }, + include: { parcels: true, players: true }, + }); + + if (!server) { + throw new ApiException(404, "SERVER_NOT_FOUND", "Server was not found."); + } + if (server.status !== ServerStatus.WAITING) { + throw new ApiException(409, "SERVER_NOT_WAITING", "Only waiting servers can be joined."); + } + + const existing = server.players.find((player) => player.userId === userId); + if (existing) { + return existing; + } + if (server.players.length >= server.maxPlayers) { + throw new ApiException(409, "SERVER_FULL", "Server is already full."); + } + + const assignedParcelIds = new Set(server.players.map((player) => player.parcelId)); + const availableParcels = server.parcels.filter( + (parcel) => !assignedParcelIds.has(parcel.id), + ); + if (availableParcels.length === 0) { + throw new ApiException(409, "NO_AVAILABLE_PARCELS", "No parcels are available."); + } + + const parcel = availableParcels[Math.floor(Math.random() * availableParcels.length)]; + const config = defaultEngineConfig(server); + const player = await tx.player.create({ + data: { + userId, + serverId, + parcelId: parcel.id, + wealth: config.startingWealth, + }, + }); + await tx.parcel.update({ where: { id: parcel.id }, data: { ownerId: player.id } }); + return player; + }); + +export const createServerMap = async ({ + serverId, + width, + height, +}: { + serverId: string; + width: number; + height: number; +}) => + prisma.$transaction(async (tx) => { + const server = await tx.server.findUnique({ + where: { id: serverId }, + include: { players: true }, + }); + if (!server) { + throw new ApiException(404, "SERVER_NOT_FOUND", "Server was not found."); + } + if (server.players.length > 0) { + throw new ApiException(409, "PLAYERS_ALREADY_JOINED", "Cannot replace a map after players join."); + } + + await tx.parcel.deleteMany({ where: { serverId } }); + const parcels = generateMap({ + seed: server.randomSeed, + inequality: server.inequalityCondition, + width, + height, + }); + await tx.parcel.createMany({ + data: parcels.map((parcel) => ({ + serverId, + x: parcel.x, + y: parcel.y, + soil: parcel.soil, + water: parcel.water, + marketAccess: parcel.marketAccess, + risk: parcel.risk, + quality: parcel.quality, + })), + }); + await tx.server.update({ + where: { id: serverId }, + data: { config: { ...jsonObject(server.config), mapWidth: width, mapHeight: height } }, + }); + return { count: parcels.length }; + }); + +const enginePlayer = (player: EnginePlayerRecord, 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: EngineParcelRecord): 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 engineDecision = (decision: EngineDecisionRecord): EngineDecision => ({ + playerId: decision.playerId, + type: decision.actionType, + amount: toNumber(decision.amount), + parcelId: jsonObject(decision.payload).parcelId as string | undefined, + counterpartyId: decision.targetPlayerId ?? undefined, +}); + +export const submitPlayerDecisions = async ({ + userId, + serverId, + decisions, +}: { + userId: string; + serverId: string; + decisions: Array<{ + type: ActionType; + amount?: number; + parcelId?: string; + counterpartyId?: string; + payload?: Record; + }>; +}) => { + const server = await prisma.server.findUnique({ + where: { id: serverId }, + include: { + rounds: { where: { status: RoundStatus.ACTIVE }, orderBy: { roundNumber: "desc" }, take: 1 }, + parcels: true, + players: { include: { parcel: true } }, + }, + }); + + if (!server) { + throw new ApiException(404, "SERVER_NOT_FOUND", "Server was not found."); + } + if (server.status !== ServerStatus.ACTIVE || server.rounds.length === 0) { + throw new ApiException(409, "ROUND_NOT_ACTIVE", "No round is active for this server."); + } + + const player = server.players.find((candidate) => candidate.userId === userId); + if (!player) { + throw new ApiException(403, "FORBIDDEN", "You are not a participant on this server."); + } + + const activeRound = server.rounds[0]; + const existingDecisions = await prisma.decision.findMany({ + where: { serverId, roundNumber: activeRound.roundNumber }, + }); + const config = defaultEngineConfig(server); + const candidateDecisions: EngineDecision[] = [ + ...existingDecisions.map(engineDecision), + ...decisions.map((decision) => ({ + playerId: player.id, + type: decision.type, + amount: decision.amount, + parcelId: decision.parcelId, + counterpartyId: decision.counterpartyId, + })), + ]; + const validation = validateDecisions({ + players: server.players.map((candidate) => enginePlayer(candidate, config.actionPointsPerRound)), + parcels: server.parcels.map(engineParcel), + decisions: candidateDecisions, + config, + }); + if (!validation.ok) { + throw new ApiException(400, "INVALID_DECISIONS", "One or more decisions are invalid.", validation.errors); + } + + const totalCost = decisions.reduce( + (total, decision) => + total + decisionCost({ playerId: player.id, type: decision.type, amount: decision.amount, parcelId: decision.parcelId, counterpartyId: decision.counterpartyId }, config), + 0, + ); + if (totalCost > toNumber(player.wealth)) { + throw new ApiException(400, "INSUFFICIENT_WEALTH", "Player does not have enough wealth."); + } + + await prisma.decision.createMany({ + data: decisions.map((decision) => ({ + playerId: player.id, + serverId, + roundNumber: activeRound.roundNumber, + actionType: decision.type, + amount: decision.amount ?? 0, + targetPlayerId: decision.counterpartyId, + payload: { ...(decision.payload ?? {}), parcelId: decision.parcelId }, + })), + }); + + return { roundNumber: activeRound.roundNumber, count: decisions.length }; +}; + +export const startServer = async (serverId: string) => + prisma.$transaction(async (tx) => { + const server = await tx.server.findUnique({ where: { id: serverId }, include: { parcels: true } }); + if (!server) { + throw new ApiException(404, "SERVER_NOT_FOUND", "Server was not found."); + } + if (server.status !== ServerStatus.WAITING) { + throw new ApiException(409, "SERVER_NOT_WAITING", "Only waiting servers can be started."); + } + if (server.parcels.length === 0) { + throw new ApiException(409, "MAP_REQUIRED", "Generate a map before starting the server."); + } + const now = new Date(); + const season = await tx.season.create({ + data: { + serverId, + startsAt: now, + endsAt: new Date(now.getTime() + server.seasonLength * 24 * 60 * 60 * 1000), + status: SeasonStatus.ACTIVE, + }, + }); + const round = await tx.round.create({ + data: { + serverId, + seasonId: season.id, + roundNumber: 1, + status: RoundStatus.ACTIVE, + startsAt: now, + endsAt: new Date(now.getTime() + 24 * 60 * 60 * 1000), + }, + }); + await tx.server.update({ where: { id: serverId }, data: { status: ServerStatus.ACTIVE, currentRound: 0 } }); + return { season, round }; + }); + +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; +}; + +export const resolveActiveRound = async (serverId: string) => + prisma.$transaction(async (tx) => { + const server = await tx.server.findUnique({ + where: { id: serverId }, + include: { + players: { include: { parcel: true } }, + parcels: true, + contracts: true, + rounds: { where: { status: RoundStatus.ACTIVE }, orderBy: { roundNumber: "desc" }, take: 1 }, + seasons: { where: { status: SeasonStatus.ACTIVE }, take: 1 }, + }, + }); + if (!server) { + throw new ApiException(404, "SERVER_NOT_FOUND", "Server was not found."); + } + if (server.status !== ServerStatus.ACTIVE || server.rounds.length === 0) { + throw new ApiException(409, "ROUND_NOT_ACTIVE", "No active round can be resolved."); + } + + const round = server.rounds[0]; + const config = defaultEngineConfig(server); + const decisions = await tx.decision.findMany({ where: { serverId, roundNumber: round.roundNumber } }); + const result = resolveRound({ + server: { + round: server.currentRound, + taxRate: config.taxRate, + formalContractFee: config.formalContractFee, + shockProbability: config.shockProbability, + treasury: toNumber(server.treasury), + uncertainty: server.uncertaintyCondition, + events: [], + } satisfies ServerState, + players: server.players.map((player) => enginePlayer(player, config.actionPointsPerRound)), + parcels: server.parcels.map(engineParcel), + decisions: decisions.map(engineDecision), + config, + seed: server.randomSeed, + }); + + 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 ? round.roundNumber : undefined, + }, + }); + await tx.playerRoundState.upsert({ + where: { playerId_roundNumber: { playerId: player.id, roundNumber: round.roundNumber } }, + update: { + wealth: player.wealth, + productiveCapital: player.productiveCapital, + safeAsset: player.safeAssets, + exited: player.exited, + state: { roundSummary: result.roundSummary }, + }, + create: { + playerId: player.id, + serverId, + roundNumber: round.roundNumber, + wealth: player.wealth, + productiveCapital: player.productiveCapital, + safeAsset: player.safeAssets, + reputation: 0, + exited: player.exited, + state: { roundSummary: result.roundSummary }, + }, + }); + } + + if (result.contracts.length > 0) { + await tx.contract.createMany({ + data: result.contracts.map((contract) => ({ + senderId: contract.fromPlayerId, + receiverId: contract.toPlayerId, + serverId, + roundNumber: round.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: round.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: round.roundNumber, + type: transactionType(transaction.reason), + amount: transaction.amount, + description: transaction.reason, + })), + }); + } + + const nextConfig = { + ...jsonObject(server.config), + taxRate: result.server.taxRate, + formalContractFee: result.server.formalContractFee, + shockProbability: result.server.shockProbability, + }; + const completed = round.roundNumber >= server.seasonLength; + await tx.round.update({ where: { id: round.id }, data: { status: RoundStatus.RESOLVED } }); + if (completed) { + await tx.server.update({ + where: { id: serverId }, + data: { + currentRound: round.roundNumber, + treasury: result.server.treasury, + status: ServerStatus.COMPLETED, + config: nextConfig, + }, + }); + if (server.seasons[0]) { + await tx.season.update({ where: { id: server.seasons[0].id }, data: { status: SeasonStatus.COMPLETED, endsAt: new Date() } }); + } + } else { + await tx.server.update({ + where: { id: serverId }, + data: { + currentRound: round.roundNumber, + treasury: result.server.treasury, + config: nextConfig, + }, + }); + await tx.round.create({ + data: { + serverId, + seasonId: server.seasons[0]?.id ?? round.seasonId, + roundNumber: round.roundNumber + 1, + status: RoundStatus.ACTIVE, + startsAt: new Date(), + endsAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }); + } + + return result.roundSummary; + }); diff --git a/apps/web/package.json b/apps/web/package.json index 94de7b0..d351ac5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,7 +14,10 @@ "@parcel-society/shared": "workspace:*", "next": "^15.3.2", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "@parcel-society/db": "workspace:*", + "@parcel-society/engine": "workspace:*", + "zod": "^3.24.4" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.6", diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 8cacfa8..ea4592d 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -5,11 +5,25 @@ "jsx": "preserve", "incremental": true, "baseUrl": ".", - "plugins": [{ "name": "next" }], + "plugins": [ + { + "name": "next" + } + ], "paths": { - "@/*": ["./src/*"] - } + "@/*": [ + "./src/*" + ] + }, + "ignoreDeprecations": "6.0" }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] }