diff --git a/apps/web/app/api/servers/[serverId]/final-summary/route.ts b/apps/web/app/api/servers/[serverId]/final-summary/route.ts index 7cb97fb..9e3223d 100644 --- a/apps/web/app/api/servers/[serverId]/final-summary/route.ts +++ b/apps/web/app/api/servers/[serverId]/final-summary/route.ts @@ -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); } diff --git a/apps/web/app/game/[serverId]/final/page.tsx b/apps/web/app/game/[serverId]/final/page.tsx index e7e55c3..6b4cab7 100644 --- a/apps/web/app/game/[serverId]/final/page.tsx +++ b/apps/web/app/game/[serverId]/final/page.tsx @@ -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); @@ -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]); @@ -65,7 +66,7 @@ export default function FinalPage() {

Server-level outcomes

Server status
{data?.server?.status ?? "Unknown"}
-
Participants
{data?.server?.players.length ?? 0}
+
Participants
{data?.serverOutcomes.participantCount ?? 0}
Exited participants
{totals.exits}
diff --git a/apps/web/lib/api/auth.ts b/apps/web/lib/api/auth.ts index e528865..5cbdc22 100644 --- a/apps/web/lib/api/auth.ts +++ b/apps/web/lib/api/auth.ts @@ -1,3 +1,4 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; import { cookies } from "next/headers"; import { prisma, UserRole } from "@parcel-society/db"; import { ApiException } from "./responses"; @@ -5,6 +6,31 @@ 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; @@ -17,7 +43,7 @@ export type AuthContext = { export const getParticipantAuth = async (): Promise => { 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({ @@ -33,7 +59,7 @@ export const getParticipantAuth = async (): Promise => { }); return { user, - setCookie: { name: PARTICIPANT_COOKIE, value: user.id }, + setCookie: { name: PARTICIPANT_COOKIE, value: encodeParticipantCookie(user.id) }, }; }; @@ -149,7 +175,7 @@ export const applyAuthCookie = ( 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; diff --git a/apps/web/lib/services/game.ts b/apps/web/lib/services/game.ts index 1d9a760..de6f657 100644 --- a/apps/web/lib/services/game.ts +++ b/apps/web/lib/services/game.ts @@ -12,6 +12,7 @@ import { type Prisma, } from "@parcel-society/db"; import { + createRandom, decisionCost, generateMap, resolveRound, @@ -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, }; }; @@ -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: { diff --git a/apps/web/lib/services/researchExport.ts b/apps/web/lib/services/researchExport.ts index adbb5f6..2974bc8 100644 --- a/apps/web/lib/services/researchExport.ts +++ b/apps/web/lib/services/researchExport.ts @@ -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", @@ -225,6 +235,7 @@ export const buildResearchExportZip = async (scope: ExportScope): Promise ({ + 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), @@ -407,5 +439,6 @@ export const buildResearchExportZip = async (scope: ExportScope): Promise { ).toBe(true); }); }); + +describe("research treatment reproducibility", () => { + it("only applies configured uncertainty rule changes on uncertain servers", () => { + const stable = resolveRound({ + server: createInitialServerState({ + ...config, + uncertainty: "STABLE", + uncertaintyRuleChangeRounds: [1], + uncertaintyPossibleEvents: ["TAX_CHANGE"], + }), + players: createInitialPlayers(1, config), + parcels: [ownedParcel()], + decisions: [], + config: { + ...config, + uncertainty: "STABLE", + uncertaintyRuleChangeRounds: [1], + uncertaintyPossibleEvents: ["TAX_CHANGE"], + }, + seed: "uncertainty-seed", + }); + const uncertain = resolveRound({ + server: createInitialServerState({ + ...config, + uncertainty: "UNCERTAIN", + uncertaintyRuleChangeRounds: [1], + uncertaintyPossibleEvents: ["TAX_CHANGE"], + }), + players: createInitialPlayers(1, config), + parcels: [ownedParcel()], + decisions: [], + config: { + ...config, + uncertainty: "UNCERTAIN", + uncertaintyRuleChangeRounds: [1], + uncertaintyPossibleEvents: ["TAX_CHANGE"], + }, + seed: "uncertainty-seed", + }); + + expect(stable.serverEvents).toHaveLength(0); + expect(uncertain.serverEvents).toHaveLength(1); + expect(uncertain.serverEvents[0]?.type).toBe("TAX_CHANGE"); + }); +}); diff --git a/packages/engine/src/roundResolver.ts b/packages/engine/src/roundResolver.ts index ed72bde..2aceeb5 100644 --- a/packages/engine/src/roundResolver.ts +++ b/packages/engine/src/roundResolver.ts @@ -24,6 +24,7 @@ export const resolveRound = ({ server: { ...server }, seed, round, + config, }); const workingServer = ruleChange.server; const validation = validateDecisions({ diff --git a/packages/engine/src/serverSimulator.ts b/packages/engine/src/serverSimulator.ts index 0b2aa5d..197b5cb 100644 --- a/packages/engine/src/serverSimulator.ts +++ b/packages/engine/src/serverSimulator.ts @@ -12,6 +12,13 @@ import type { ServerState, } from "./types"; +const DEFAULT_RULE_CHANGE_ROUNDS: readonly number[] = [3, 5]; +const DEFAULT_RULE_CHANGE_EVENTS: readonly RuleChangeEventType[] = [ + "TAX_CHANGE", + "FORMAL_CONTRACT_FEE_CHANGE", + "SHOCK_PROBABILITY_CHANGE", +]; + export const DEFAULT_ENGINE_CONFIG: EngineConfig = { seed: "parcel-society", inequality: "LOW", @@ -31,6 +38,8 @@ export const DEFAULT_ENGINE_CONFIG: EngineConfig = { safeAssetReturn: 0.02, publicGoodMultiplier: 1.4, lobbyingCost: 5, + uncertaintyRuleChangeRounds: DEFAULT_RULE_CHANGE_ROUNDS, + uncertaintyPossibleEvents: DEFAULT_RULE_CHANGE_EVENTS, }; export const createInitialServerState = ( @@ -67,21 +76,28 @@ export const applyRuleChangeIfNeeded = ({ server, seed, round, + config, }: { server: ServerState; seed: string | number; round: number; + config?: Pick; }): { server: ServerState; events: ServerEvent[] } => { - if (server.uncertainty !== "UNCERTAIN" || (round !== 3 && round !== 5)) { + const ruleChangeRounds = config?.uncertaintyRuleChangeRounds ?? DEFAULT_RULE_CHANGE_ROUNDS; + const possibleEvents = config?.uncertaintyPossibleEvents?.length + ? config.uncertaintyPossibleEvents + : DEFAULT_RULE_CHANGE_EVENTS; + + if ( + server.uncertainty !== "UNCERTAIN" || + !ruleChangeRounds.includes(round) || + possibleEvents.length === 0 + ) { return { server, events: [] }; } const random = createRandom(`${seed}:rule-change:${round}`); - const eventType = random.pick([ - "TAX_CHANGE", - "FORMAL_CONTRACT_FEE_CHANGE", - "SHOCK_PROBABILITY_CHANGE", - ]); + const eventType = random.pick(possibleEvents); const nextServer = { ...server, events: [...server.events] }; const direction = random.boolean(0.5) ? 1 : -1; const event: ServerEvent = { diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index b5b4e39..b7c0421 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -35,6 +35,8 @@ export interface EngineConfig { safeAssetReturn: number; publicGoodMultiplier: number; lobbyingCost: number; + uncertaintyRuleChangeRounds?: readonly number[]; + uncertaintyPossibleEvents?: readonly RuleChangeEventType[]; } export interface ProductionConfig {