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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions apps/web/app/api/servers/[serverId]/final-summary/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,39 @@ export async function GET(_request: Request, context: { params: Promise<{ server
const { serverId } = serverIdParamsSchema.parse(await context.params);
const auth = await getParticipantAuth();
const player = await assertParticipantOnServer(auth.user.id, serverId);
const [server, decisions, contracts] = await Promise.all([
prisma.server.findUnique({ where: { id: serverId }, include: { players: true } }),
const [server, decisions, contracts, participantCount, exitedCount, wealthAggregate] = await Promise.all([
prisma.server.findUnique({
where: { id: serverId },
select: {
id: true,
name: true,
status: true,
treasury: true,
currentRound: true,
seasonLength: true,
inequalityCondition: true,
uncertaintyCondition: true,
},
}),
prisma.decision.findMany({ where: { serverId, playerId: player.id } }),
prisma.contract.findMany({
where: { serverId, OR: [{ senderId: player.id }, { receiverId: player.id }] },
}),
prisma.player.count({ where: { serverId } }),
prisma.player.count({ where: { serverId, exited: true } }),
prisma.player.aggregate({ where: { serverId }, _avg: { wealth: true } }),
]);
return applyAuthCookie(apiOk({ server, player, decisions, contracts }), auth);
return applyAuthCookie(apiOk({
server,
player,
decisions,
contracts,
serverOutcomes: {
participantCount,
exitedCount,
averageWealth: wealthAggregate._avg.wealth ?? 0,
},
}), auth);
} catch (error) {
return handleApiError(error);
}
Expand Down
11 changes: 6 additions & 5 deletions apps/web/app/game/[serverId]/final/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ type DecimalValue = number | string | { toString(): string };
type Player = { wealth: DecimalValue; productiveCapital: DecimalValue; safeAsset: DecimalValue; exited: boolean };
type Decision = { actionType: string; amount: DecimalValue };
type Contract = { fulfilled: boolean | null; defaulted: boolean | null };
type Server = { status: string; treasury: DecimalValue; players: Array<{ exited: boolean; wealth: DecimalValue }> };
type FinalResponse = { server: Server | null; player: Player; decisions: Decision[]; contracts: Contract[] };
type Server = { status: string; treasury: DecimalValue };
type ServerOutcomes = { participantCount: number; exitedCount: number; averageWealth: DecimalValue };
type FinalResponse = { server: Server | null; player: Player; decisions: Decision[]; contracts: Contract[]; serverOutcomes: ServerOutcomes };

const money = (value: DecimalValue | undefined) => Number(value ?? 0).toFixed(2);

Expand All @@ -36,8 +37,8 @@ export default function FinalPage() {
investments: decisions.filter((decision) => decision.actionType === "PRODUCTIVE_INVESTMENT").reduce((sum, decision) => sum + Number(decision.amount), 0),
reliability: resolved === 0 ? "No resolved contracts" : `${Math.round((fulfilled / resolved) * 100)}% fulfilled`,
exited: data?.player.exited || decisions.some((decision) => decision.actionType === "EXIT"),
averageWealth: data?.server?.players.length ? data.server.players.reduce((sum, player) => sum + Number(player.wealth), 0) / data.server.players.length : 0,
exits: data?.server?.players.filter((player) => player.exited).length ?? 0,
averageWealth: Number(data?.serverOutcomes.averageWealth ?? 0),
exits: data?.serverOutcomes.exitedCount ?? 0,
};
}, [data]);

Expand Down Expand Up @@ -65,7 +66,7 @@ export default function FinalPage() {
<h2 className="font-semibold text-slate-950">Server-level outcomes</h2>
<dl className="mt-4 grid gap-3 text-sm md:grid-cols-3">
<div className="rounded-lg bg-slate-50 p-3"><dt className="text-slate-500">Server status</dt><dd className="font-semibold text-slate-900">{data?.server?.status ?? "Unknown"}</dd></div>
<div className="rounded-lg bg-slate-50 p-3"><dt className="text-slate-500">Participants</dt><dd className="font-semibold text-slate-900">{data?.server?.players.length ?? 0}</dd></div>
<div className="rounded-lg bg-slate-50 p-3"><dt className="text-slate-500">Participants</dt><dd className="font-semibold text-slate-900">{data?.serverOutcomes.participantCount ?? 0}</dd></div>
<div className="rounded-lg bg-slate-50 p-3"><dt className="text-slate-500">Exited participants</dt><dd className="font-semibold text-slate-900">{totals.exits}</dd></div>
</dl>
</article>
Expand Down
32 changes: 29 additions & 3 deletions apps/web/lib/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,36 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { cookies } from "next/headers";
import { prisma, UserRole } from "@parcel-society/db";
import { ApiException } from "./responses";
import { rateLimit } from "./rateLimit";

const PARTICIPANT_COOKIE = "parcel_society_user_id";

const appSecret = (): string => {
const secret = process.env.APP_SECRET;
if (secret) return secret;
if (process.env.NODE_ENV !== "production") return "parcel-society-development-secret";
throw new ApiException(500, "APP_SECRET_NOT_CONFIGURED", "Participant session signing is not configured.");
};

const signParticipantId = (userId: string): string =>
createHmac("sha256", appSecret()).update(userId).digest("base64url");

const encodeParticipantCookie = (userId: string): string => `${userId}.${signParticipantId(userId)}`;

const decodeParticipantCookie = (value: string | undefined): string | null => {
if (!value) return null;
const separator = value.lastIndexOf(".");
if (separator <= 0) return null;
const userId = value.slice(0, separator);
const signature = value.slice(separator + 1);
const expected = signParticipantId(userId);
const signatureBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expected);
if (signatureBuffer.length !== expectedBuffer.length) return null;
return timingSafeEqual(signatureBuffer, expectedBuffer) ? userId : null;
};

export type AuthContext = {
user: {
id: string;
Expand All @@ -17,7 +43,7 @@ export type AuthContext = {

export const getParticipantAuth = async (): Promise<AuthContext> => {
const cookieStore = await cookies();
const existingUserId = cookieStore.get(PARTICIPANT_COOKIE)?.value;
const existingUserId = decodeParticipantCookie(cookieStore.get(PARTICIPANT_COOKIE)?.value);

if (existingUserId) {
const user = await prisma.user.findUnique({
Expand All @@ -33,7 +59,7 @@ export const getParticipantAuth = async (): Promise<AuthContext> => {
});
return {
user,
setCookie: { name: PARTICIPANT_COOKIE, value: user.id },
setCookie: { name: PARTICIPANT_COOKIE, value: encodeParticipantCookie(user.id) },
};
};

Expand Down Expand Up @@ -149,7 +175,7 @@ export const applyAuthCookie = <T extends Response>(
if (auth.setCookie) {
response.headers.append(
"Set-Cookie",
`${auth.setCookie.name}=${auth.setCookie.value}; Path=/; HttpOnly; SameSite=Lax`,
`${auth.setCookie.name}=${auth.setCookie.value}; Path=/; HttpOnly; SameSite=Lax${process.env.NODE_ENV === "production" ? "; Secure" : ""}`,
);
}
return response;
Expand Down
16 changes: 15 additions & 1 deletion apps/web/lib/services/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type Prisma,
} from "@parcel-society/db";
import {
createRandom,
decisionCost,
generateMap,
resolveRound,
Expand Down Expand Up @@ -98,6 +99,16 @@ export const defaultEngineConfig = (server: {
safeAssetReturn: Number(overrides.safeAssetReturn ?? 0.03),
publicGoodMultiplier: Number(overrides.publicGoodMultiplier ?? 1.5),
lobbyingCost: Number(overrides.lobbyingCost ?? 5),
uncertaintyRuleChangeRounds: Array.isArray(overrides.uncertaintyRuleChangeRounds)
? overrides.uncertaintyRuleChangeRounds.map(Number).filter(Number.isFinite)
: undefined,
uncertaintyPossibleEvents: Array.isArray(overrides.uncertaintyPossibleEvents)
? overrides.uncertaintyPossibleEvents.filter((event): event is "TAX_CHANGE" | "FORMAL_CONTRACT_FEE_CHANGE" | "SHOCK_PROBABILITY_CHANGE" =>
event === "TAX_CHANGE" ||
event === "FORMAL_CONTRACT_FEE_CHANGE" ||
event === "SHOCK_PROBABILITY_CHANGE",
)
: undefined,
};
};

Expand Down Expand Up @@ -137,7 +148,10 @@ export const joinWaitingServer = async ({
throw new ApiException(409, "NO_AVAILABLE_PARCELS", "No parcels are available.");
}

const parcel = availableParcels[Math.floor(Math.random() * availableParcels.length)];
const orderedAvailableParcels = [...availableParcels].sort(
(left, right) => left.y - right.y || left.x - right.x || left.id.localeCompare(right.id),
);
const parcel = createRandom(`${server.randomSeed}:join:${server.players.length}`).pick(orderedAvailableParcels);
const config = defaultEngineConfig(server);
const player = await tx.player.create({
data: {
Expand Down
33 changes: 33 additions & 0 deletions apps/web/lib/services/researchExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,16 @@ const roundOutcomeHeaders = [
"average_wealth",
"median_wealth",
];
const serverConfigHeaders = [
"server_id",
"inequality_condition",
"uncertainty_condition",
"random_seed",
"config_key",
"config_json",
"created_at",
"updated_at",
];
const serverSummaryHeaders = [
"server_id",
"inequality_condition",
Expand Down Expand Up @@ -225,6 +235,7 @@ export const buildResearchExportZip = async (scope: ExportScope): Promise<Uint8A
events: { orderBy: [{ serverId: "asc" }, { roundNumber: "asc" }, { createdAt: "asc" }] },
treasuryTransactions: { orderBy: [{ serverId: "asc" }, { roundNumber: "asc" }, { createdAt: "asc" }] },
playerRoundStates: { orderBy: [{ serverId: "asc" }, { roundNumber: "asc" }, { playerId: "asc" }] },
serverConfigs: { orderBy: [{ key: "asc" }, { createdAt: "asc" }] },
},
});

Expand All @@ -240,6 +251,7 @@ export const buildResearchExportZip = async (scope: ExportScope): Promise<Uint8A
const transactionRows: CsvRow[] = [];
const roundOutcomeRows: CsvRow[] = [];
const serverSummaryRows: CsvRow[] = [];
const serverConfigRows: CsvRow[] = [];

for (const server of servers) {
const config = defaultEngineConfig(server);
Expand All @@ -250,6 +262,26 @@ export const buildResearchExportZip = async (scope: ExportScope): Promise<Uint8A
const events = server.events;
const treasuryTransactions = server.treasuryTransactions;
const playerRoundStates = server.playerRoundStates;
serverConfigRows.push({
server_id: server.id,
inequality_condition: server.inequalityCondition,
uncertainty_condition: server.uncertaintyCondition,
random_seed: server.randomSeed,
config_key: "engineOverrides",
config_json: server.config,
created_at: server.createdAt,
updated_at: server.updatedAt,
});
serverConfigRows.push(...server.serverConfigs.map((configEntry) => ({
server_id: server.id,
inequality_condition: server.inequalityCondition,
uncertainty_condition: server.uncertaintyCondition,
random_seed: server.randomSeed,
config_key: configEntry.key,
config_json: configEntry.value,
created_at: configEntry.createdAt,
updated_at: configEntry.updatedAt,
})));
const rounds = [...new Set([
...decisions.map((decision) => decision.roundNumber),
...contracts.map((contract) => contract.roundNumber),
Expand Down Expand Up @@ -407,5 +439,6 @@ export const buildResearchExportZip = async (scope: ExportScope): Promise<Uint8A
"treasury_transactions.csv": tableToCsv(transactionHeaders, transactionRows),
"round_outcomes.csv": tableToCsv(roundOutcomeHeaders, roundOutcomeRows),
"server_summary.csv": tableToCsv(serverSummaryHeaders, serverSummaryRows),
"server_configs.csv": tableToCsv(serverConfigHeaders, serverConfigRows),
});
};
2 changes: 1 addition & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Run database schema deployment after the database is healthy:
make migrate
```

The current deployment command uses Prisma `db push` because this repository does not yet include migration files. If versioned Prisma migrations are added later, change `packages/db/package.json` to use `prisma migrate deploy` for `db:deploy`.
Production deployments use versioned Prisma migrations via `prisma migrate deploy`; do not use `db push` against production data.

## Create the first admin

Expand Down
2 changes: 1 addition & 1 deletion packages/db/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"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 migrate deploy --schema prisma/schema.prisma",
"seed:demo": "tsx scripts/seed-demo.ts"
},
"dependencies": {
Expand Down
Loading
Loading