diff --git a/.claude/settings.json b/.claude/settings.json index db5172b..200bba3 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,6 +2,7 @@ "permissions": { "allow": [ "Bash(node -e *)", + "Bash(python3 *)", "Bash(node --input-type=module)", "Bash(2>&1)", "Bash(pnpm *)" diff --git a/apps/api/prisma/migrations/20260507000000_initial_schema/migration.sql b/apps/api/prisma/migrations/20260507000000_initial_schema/migration.sql new file mode 100644 index 0000000..486bc9b --- /dev/null +++ b/apps/api/prisma/migrations/20260507000000_initial_schema/migration.sql @@ -0,0 +1,91 @@ +-- CreateTable +CREATE TABLE "users" ( + "id" UUID NOT NULL, + "twitch_opaque_user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "survivors" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "channel_id" TEXT NOT NULL, + "name" VARCHAR(32) NOT NULL, + "state" TEXT NOT NULL DEFAULT 'active', + "stats" JSONB NOT NULL, + "perk_slots" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "survivors_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "missions" ( + "id" UUID NOT NULL, + "group_id" UUID, + "channel_id" TEXT NOT NULL, + "difficulty" SMALLINT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "encounter_library_version" TEXT NOT NULL, + "started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ended_at" TIMESTAMP(3), + "tick_index" INTEGER NOT NULL DEFAULT 0, + "next_tick_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "missions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "mission_participants" ( + "id" UUID NOT NULL, + "mission_id" UUID NOT NULL, + "survivor_id" UUID NOT NULL, + "state" TEXT NOT NULL DEFAULT 'active', + "hook_count" SMALLINT NOT NULL DEFAULT 0, + + CONSTRAINT "mission_participants_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "mission_logs" ( + "id" UUID NOT NULL, + "mission_id" UUID NOT NULL, + "tick_index" INTEGER NOT NULL, + "encounter_key" TEXT NOT NULL, + "rendered_text" TEXT NOT NULL, + "seed" TEXT NOT NULL, + "modifiers_applied" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "mission_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_twitch_opaque_user_id_key" ON "users"("twitch_opaque_user_id"); + +-- CreateIndex +CREATE INDEX "missions_channel_id_idx" ON "missions"("channel_id"); + +-- CreateIndex +CREATE INDEX "missions_status_next_tick_at_idx" ON "missions"("status", "next_tick_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "mission_participants_mission_id_survivor_id_key" ON "mission_participants"("mission_id", "survivor_id"); + +-- CreateIndex +CREATE INDEX "mission_logs_mission_id_tick_index_idx" ON "mission_logs"("mission_id", "tick_index"); + +-- AddForeignKey +ALTER TABLE "survivors" ADD CONSTRAINT "survivors_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "mission_participants" ADD CONSTRAINT "mission_participants_mission_id_fkey" FOREIGN KEY ("mission_id") REFERENCES "missions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "mission_participants" ADD CONSTRAINT "mission_participants_survivor_id_fkey" FOREIGN KEY ("survivor_id") REFERENCES "survivors"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "mission_logs" ADD CONSTRAINT "mission_logs_mission_id_fkey" FOREIGN KEY ("mission_id") REFERENCES "missions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..edbaeb1 --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -0,0 +1,80 @@ +generator client { + provider = "prisma-client-js" + output = "../../../node_modules/.prisma/client" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(uuid()) @db.Uuid + opaqueUserId String @unique @map("twitch_opaque_user_id") + createdAt DateTime @default(now()) @map("created_at") + survivors Survivor[] + + @@map("users") +} + +model Survivor { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + user User @relation(fields: [userId], references: [id]) + channelId String @map("channel_id") + name String @db.VarChar(32) + state String @default("active") + stats Json + perkSlots Json @map("perk_slots") + createdAt DateTime @default(now()) @map("created_at") + participants MissionParticipant[] + + @@map("survivors") +} + +model Mission { + id String @id @default(uuid()) @db.Uuid + groupId String? @map("group_id") @db.Uuid + channelId String @map("channel_id") + difficulty Int @db.SmallInt + status String @default("active") + encounterLibraryVersion String @map("encounter_library_version") + startedAt DateTime @default(now()) @map("started_at") + endedAt DateTime? @map("ended_at") + tickIndex Int @default(0) @map("tick_index") + nextTickAt DateTime @map("next_tick_at") + participants MissionParticipant[] + logs MissionLog[] + + @@index([channelId]) + @@index([status, nextTickAt]) + @@map("missions") +} + +model MissionParticipant { + id String @id @default(uuid()) @db.Uuid + missionId String @map("mission_id") @db.Uuid + mission Mission @relation(fields: [missionId], references: [id]) + survivorId String @map("survivor_id") @db.Uuid + survivor Survivor @relation(fields: [survivorId], references: [id]) + state String @default("active") + hookCount Int @default(0) @map("hook_count") @db.SmallInt + + @@unique([missionId, survivorId]) + @@map("mission_participants") +} + +model MissionLog { + id String @id @default(uuid()) @db.Uuid + missionId String @map("mission_id") @db.Uuid + mission Mission @relation(fields: [missionId], references: [id]) + tickIndex Int @map("tick_index") + encounterKey String @map("encounter_key") + renderedText String @map("rendered_text") + seed String + modifiersApplied Json @map("modifiers_applied") + createdAt DateTime @default(now()) @map("created_at") + + @@index([missionId, tickIndex]) + @@map("mission_logs") +} diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index f6ed546..6b5d887 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,13 +1,23 @@ import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { MissionsModule } from './missions/missions.module'; +import { PrismaModule } from './prisma/prisma.module'; import { TickEngineModule } from './tick-engine/tick-engine.module'; @Module({ imports: [ ScheduleModule.forRoot(), + ThrottlerModule.forRoot({ + throttlers: [{ ttl: 10000, limit: 30 }], + }), + PrismaModule, MissionsModule, TickEngineModule, ], + providers: [ + { provide: APP_GUARD, useClass: ThrottlerGuard }, + ], }) export class AppModule {} diff --git a/apps/api/src/app/missions/missions.controller.ts b/apps/api/src/app/missions/missions.controller.ts index d1f9601..08645d5 100644 --- a/apps/api/src/app/missions/missions.controller.ts +++ b/apps/api/src/app/missions/missions.controller.ts @@ -1,13 +1,14 @@ import { Body, Controller, + ForbiddenException, Get, HttpCode, HttpStatus, - NotFoundException, Post, UseGuards, } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; import { MissionStateResponse, MissionStateResponseSchema, @@ -26,6 +27,7 @@ export class MissionsController { ) {} @Get('state') + @Throttle({ default: { limit: 10, ttl: 10000 } }) async getState( @TwitchClaims() claims: TwitchJwtPayload ): Promise { @@ -38,9 +40,9 @@ export class MissionsController { async startMission( @TwitchClaims() claims: TwitchJwtPayload, @Body() body: unknown - ): Promise { + ): Promise> { if (!claims.opaque_user_id.startsWith('U')) { - throw new NotFoundException('Anonymous viewers cannot start missions'); + throw new ForbiddenException('Anonymous viewers cannot start missions'); } const { difficulty } = StartMissionRequestSchema.parse(body); return this.missions.startMission(claims, difficulty); diff --git a/apps/api/src/app/missions/missions.module.ts b/apps/api/src/app/missions/missions.module.ts index b26ff7a..48e75ba 100644 --- a/apps/api/src/app/missions/missions.module.ts +++ b/apps/api/src/app/missions/missions.module.ts @@ -15,6 +15,6 @@ import { MissionsService } from './missions.service'; EncounterService, GroupSynergyService, ], - exports: [MissionStoreService, EncounterService], + exports: [MissionStoreService, EncounterService, GroupSynergyService], }) export class MissionsModule {} diff --git a/apps/api/src/app/missions/missions.service.ts b/apps/api/src/app/missions/missions.service.ts index c3b57f0..d91dd68 100644 --- a/apps/api/src/app/missions/missions.service.ts +++ b/apps/api/src/app/missions/missions.service.ts @@ -7,6 +7,7 @@ import type { } from '@fog-explorer/api-interfaces'; import { getLibraryVersion } from '@fog-explorer/encounter-library'; import { TwitchJwtPayload } from '../auth/twitch-jwt.guard'; +import { PrismaService } from '../prisma/prisma.service'; import { MissionStoreService } from './mission-store.service'; const TICK_BASE_INTERVAL_MS = 60_000; @@ -14,7 +15,10 @@ const TICK_JITTER_MS = 5_000; @Injectable() export class MissionsService { - constructor(private readonly store: MissionStoreService) {} + constructor( + private readonly store: MissionStoreService, + private readonly prisma: PrismaService + ) {} async startMission( claims: TwitchJwtPayload, @@ -22,11 +26,50 @@ export class MissionsService { ): Promise> { const missionId = crypto.randomUUID(); const survivorId = crypto.randomUUID(); - const now = new Date().toISOString(); + const now = new Date(); const jitter = Math.floor(Math.random() * TICK_JITTER_MS); - const nextTickAt = new Date(Date.now() + TICK_BASE_INTERVAL_MS + jitter).toISOString(); + const nextTickAt = new Date(now.getTime() + TICK_BASE_INTERVAL_MS + jitter); + const stats: SurvivorStats = { objectives: 5, survival: 5, altruism: 5 }; - const stats: SurvivorStats = defaultStats(); + // Upsert user and create survivor + mission in one transaction. + await this.prisma.$transaction(async (tx) => { + const user = await tx.user.upsert({ + where: { opaqueUserId: claims.opaque_user_id }, + create: { id: crypto.randomUUID(), opaqueUserId: claims.opaque_user_id }, + update: {}, + }); + + await tx.survivor.create({ + data: { + id: survivorId, + userId: user.id, + channelId: claims.channel_id, + name: defaultName(claims.opaque_user_id), + state: 'active', + stats, + perkSlots: [], + }, + }); + + await tx.mission.create({ + data: { + id: missionId, + channelId: claims.channel_id, + difficulty, + status: 'active', + encounterLibraryVersion: getLibraryVersion(), + nextTickAt, + participants: { + create: { + id: crypto.randomUUID(), + survivorId, + state: 'active', + hookCount: 0, + }, + }, + }, + }); + }); const survivor: Survivor = { id: survivorId, @@ -36,7 +79,7 @@ export class MissionsService { state: 'active', stats, perkSlots: [], - createdAt: now, + createdAt: now.toISOString(), }; const mission: Mission = { @@ -46,9 +89,9 @@ export class MissionsService { difficulty, status: 'active', encounterLibraryVersion: getLibraryVersion(), - nextTickAt, + nextTickAt: nextTickAt.toISOString(), tickIndex: 0, - startedAt: now, + startedAt: now.toISOString(), endedAt: null, }; @@ -60,16 +103,12 @@ export class MissionsService { await this.store.setActiveMission(state); await this.store.setChannelMissionId(claims.channel_id, missionId); - await this.store.scheduleTick(missionId, new Date(nextTickAt).getTime()); + await this.store.scheduleTick(missionId, nextTickAt.getTime()); return state; } } -function defaultStats(): SurvivorStats { - return { objectives: 5, survival: 5, altruism: 5 }; -} - function defaultName(opaqueUserId: string): string { return `Survivor ${opaqueUserId.slice(-4)}`; } diff --git a/apps/api/src/app/prisma/prisma.module.ts b/apps/api/src/app/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/apps/api/src/app/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/api/src/app/prisma/prisma.service.ts b/apps/api/src/app/prisma/prisma.service.ts new file mode 100644 index 0000000..829d52f --- /dev/null +++ b/apps/api/src/app/prisma/prisma.service.ts @@ -0,0 +1,13 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit(): Promise { + await this.$connect(); + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + } +} diff --git a/apps/api/src/app/tick-engine/tick-engine.module.ts b/apps/api/src/app/tick-engine/tick-engine.module.ts index 16b1fe2..8cc8128 100644 --- a/apps/api/src/app/tick-engine/tick-engine.module.ts +++ b/apps/api/src/app/tick-engine/tick-engine.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { MissionsModule } from '../missions/missions.module'; import { TickService } from './tick.service'; +import { TwitchPubSubService } from './twitch-pubsub.service'; @Module({ imports: [MissionsModule], - providers: [TickService], + providers: [TickService, TwitchPubSubService], }) export class TickEngineModule {} diff --git a/apps/api/src/app/tick-engine/tick.service.ts b/apps/api/src/app/tick-engine/tick.service.ts index 11cf957..59b1b9d 100644 --- a/apps/api/src/app/tick-engine/tick.service.ts +++ b/apps/api/src/app/tick-engine/tick.service.ts @@ -1,7 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; -import { MissionStoreService } from '../missions/mission-store.service'; +import { PrismaService } from '../prisma/prisma.service'; import { EncounterService } from '../missions/encounter.service'; +import { MissionStoreService } from '../missions/mission-store.service'; +import { TwitchPubSubService } from './twitch-pubsub.service'; @Injectable() export class TickService { @@ -9,7 +11,9 @@ export class TickService { constructor( private readonly store: MissionStoreService, - private readonly encounters: EncounterService + private readonly encounters: EncounterService, + private readonly prisma: PrismaService, + private readonly pubsub: TwitchPubSubService ) {} @Cron(CronExpression.EVERY_10_SECONDS) @@ -24,7 +28,7 @@ export class TickService { private async processMission(missionId: string): Promise { const token = await this.store.acquireLock(missionId); - if (!token) return; // Another worker has the lock + if (!token) return; try { const state = await this.store.getActiveMission(missionId); @@ -34,13 +38,57 @@ export class TickService { } const updated = this.encounters.processTick(state); + const newLogs = updated.recentLog.slice( + 0, + updated.recentLog.length - state.recentLog.length + ); + // Write to Postgres in a transaction. + await this.prisma.$transaction(async (tx) => { + await tx.mission.update({ + where: { id: missionId }, + data: { + tickIndex: updated.mission.tickIndex, + nextTickAt: new Date(updated.mission.nextTickAt), + status: updated.mission.status, + endedAt: updated.mission.endedAt ? new Date(updated.mission.endedAt) : null, + }, + }); + + for (const survivor of updated.survivors) { + const participant = updated.mission.participants.find( + (p) => p.survivorId === survivor.id + ); + if (!participant) continue; + await tx.missionParticipant.updateMany({ + where: { missionId, survivorId: survivor.id }, + data: { state: participant.state, hookCount: participant.hookCount }, + }); + await tx.survivor.update({ + where: { id: survivor.id }, + data: { state: survivor.state }, + }); + } + + if (newLogs.length > 0) { + await tx.missionLog.createMany({ + data: newLogs.map((log) => ({ + id: crypto.randomUUID(), + missionId, + tickIndex: log.tickIndex, + encounterKey: log.encounterKey, + renderedText: log.logText, + seed: log.seed, + modifiersApplied: log.modifiersApplied, + })), + }); + } + }); + + // Update Redis cache. await this.store.setActiveMission(updated); - if ( - updated.mission.status === 'active' || - updated.mission.status === 'lobby' - ) { + if (updated.mission.status === 'active') { const nextMs = new Date(updated.mission.nextTickAt).getTime(); await this.store.scheduleTick(missionId, nextMs); } else { @@ -52,10 +100,22 @@ export class TickService { tickIndex: updated.mission.tickIndex, }); } + + await this.pubsub.broadcast(state.mission.participants[0] + ? await this.getChannelId(missionId) + : null, updated); } catch (err) { this.logger.error({ message: 'tick failed', missionId, err }); } finally { await this.store.releaseLock(missionId, token); } } + + private async getChannelId(missionId: string): Promise { + const mission = await this.prisma.mission.findUnique({ + where: { id: missionId }, + select: { channelId: true }, + }); + return mission?.channelId ?? null; + } } diff --git a/apps/api/src/app/tick-engine/twitch-pubsub.service.ts b/apps/api/src/app/tick-engine/twitch-pubsub.service.ts new file mode 100644 index 0000000..121c74d --- /dev/null +++ b/apps/api/src/app/tick-engine/twitch-pubsub.service.ts @@ -0,0 +1,72 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { createHmac } from 'crypto'; +import type { MissionStateResponse } from '@fog-explorer/api-interfaces'; + +const PUBSUB_URL = 'https://api.twitch.tv/extensions/message'; +const TOKEN_TTL_SECONDS = 60; + +@Injectable() +export class TwitchPubSubService { + private readonly logger = new Logger(TwitchPubSubService.name); + + async broadcast( + channelId: string | null, + state: NonNullable + ): Promise { + if (!channelId) return; + + const clientId = process.env['TWITCH_CLIENT_ID']; + const secret = process.env['TWITCH_EXTENSION_SECRET']; + if (!clientId || !secret) return; + + const token = buildServerToken(clientId, secret); + const payload = JSON.stringify({ + content_type: 'application/json', + message: JSON.stringify(state), + targets: ['broadcast'], + }); + + if (Buffer.byteLength(payload, 'utf8') > 5000) { + this.logger.warn({ message: 'pubsub payload too large, skipping', channelId }); + return; + } + + try { + const res = await fetch(`${PUBSUB_URL}/${channelId}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Client-Id': clientId, + 'Content-Type': 'application/json', + }, + body: payload, + }); + + if (!res.ok) { + this.logger.warn({ + message: 'pubsub broadcast failed', + channelId, + status: res.status, + }); + } + } catch (err) { + this.logger.error({ message: 'pubsub broadcast error', channelId, err }); + } + } +} + +function buildServerToken(clientId: string, secretB64: string): string { + const secretBytes = Buffer.from(secretB64, 'base64'); + const exp = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS; + + const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from( + JSON.stringify({ exp, user_id: clientId, role: 'external' }) + ).toString('base64url'); + + const sig = createHmac('sha256', secretBytes) + .update(`${header}.${payload}`) + .digest('base64url'); + + return `${header}.${payload}.${sig}`; +} diff --git a/libs/encounter-library/src/lib/encounters.json b/libs/encounter-library/src/lib/encounters.json index d1f93f2..5a500d3 100644 --- a/libs/encounter-library/src/lib/encounters.json +++ b/libs/encounter-library/src/lib/encounters.json @@ -9,12 +9,26 @@ "flavorSuccess": [ "The generator sputters to life. Light floods the area.", "Sparks fly, then hum — the generator catches.", - "Wires connected. The machine breathes again." + "Wires connected. The machine breathes again.", + "A deep mechanical thrum. Progress.", + "The gauge climbs. Another generator done.", + "Hands steady, heart racing. The engine turns over.", + "The generator shudders, then roars. One less in the dark.", + "Circuit reconnected. The hum is its own small victory.", + "It takes longer than expected, but the light comes.", + "The generator protests, then yields." ], "flavorFailure": [ "The generator kicks back. Too many watchers in the dark.", "The mechanism jams. Footsteps echo nearby.", - "A noise betrays the position. The generator goes cold." + "A noise betrays the position. The generator goes cold.", + "Something shifts in the fog. The work abandoned.", + "The wiring resists every attempt. No progress tonight.", + "A shadow crosses the generator. Not now.", + "The tool slips. The noise is too loud.", + "The generator sparks wrong. An alarm of sorts.", + "Patience runs out before the generator does.", + "A nearby sound. The repair will have to wait." ] }, { @@ -25,12 +39,26 @@ "flavorSuccess": [ "The totem crumbles. Its curse lifts from the fog.", "Bones scatter. The hex dissolves into smoke.", - "The ritual unravels. Something distant screams." + "The ritual unravels. Something distant screams.", + "The totem falls apart like it was never solid.", + "Ash and splinter. The hex breaks.", + "A soft crack — the bones give way. Something changes.", + "The skull shatters. Whatever watched through it is gone.", + "The cleansing takes. The fog feels thinner.", + "The totem offers no resistance. It was waiting to fall.", + "One less curse in the trial." ], "flavorFailure": [ "The totem resists. Its pull is stronger than expected.", "A presence drives the survivor back before the cleanse completes.", - "The hex holds. Dread thickens the air." + "The hex holds. Dread thickens the air.", + "Fingers close on the totem — then something warns away.", + "The totem hums with malice. Not today.", + "The ritual is interrupted. The hex burns brighter.", + "The bones refuse to break. Wrong approach.", + "The totem feels wrong. Leave it for now.", + "Whatever keeps the totem standing isn't done yet.", + "The cleanse stalls halfway. The hex endures." ] }, { @@ -41,12 +69,26 @@ "flavorSuccess": [ "The chest yields a worn medkit. Small mercies.", "A flashlight, still charged. The fog recedes slightly.", - "A useful tool among the debris." + "A useful tool among the debris.", + "Something worth having. The chest wasn't empty after all.", + "Buried under detritus — a working toolbox.", + "A medkit, half-depleted but still viable.", + "The chest creaks open. Something useful inside.", + "Luck, this time.", + "Not everything in the fog is hostile. The chest provides.", + "A find. The supplies won't last, but they'll help." ], "flavorFailure": [ "The chest is empty. Only rust and regret.", "The lid splinters — nothing useful inside.", - "A noise nearby cuts the search short." + "A noise nearby cuts the search short.", + "Someone got here first.", + "The chest is locked. No time to force it.", + "Debris, nothing more.", + "Whatever was here is long gone.", + "The chest yields nothing. Time wasted.", + "Empty. The trial takes without giving.", + "Nothing. Move on." ] }, { @@ -57,12 +99,26 @@ "flavorSuccess": [ "With grim determination, the survivor slips free.", "Arms aching, the hook releases. Freedom, for now.", - "A desperate push — the chains give way." + "A desperate push — the chains give way.", + "The hook yields to persistence.", + "Blood and effort. The barb comes free.", + "The survivor drops to the ground, gasping.", + "Will wins where strength falls short. Free.", + "The hook's grip loosens. A precious second of clarity.", + "Every instinct says stop. The survivor doesn't.", + "The chains snap. Unlikely. Necessary." ], "flavorFailure": [ "The struggle exhausts. The hook holds.", "Every movement drives the barb deeper. Stay still.", - "The fog presses in. The hook remains." + "The fog presses in. The hook remains.", + "The attempt fails. The chains don't care.", + "No leverage. No escape. Not yet.", + "The energy spent is wasted. Still trapped.", + "The hook was designed for this. It holds.", + "Patience isn't a luxury here — but neither is panic.", + "The barb shifts but doesn't release.", + "The struggle ends. The hook wins this round." ] }, { @@ -73,12 +129,26 @@ "flavorSuccess": [ "The gate grinds open. Cold air rushes in.", "Generators humming, the lock gives. Almost there.", - "The exit yields. Light from outside cuts the fog." + "The exit yields. Light from outside cuts the fog.", + "The switch holds down. The gate obeys.", + "A long, agonising second — then the gate opens.", + "The exit panel responds. The way out is clear.", + "The gate was always going to open. Tonight it does.", + "The outside world, briefly visible through iron.", + "The switch trips. The gate moves.", + "One last obstacle overcome." ], "flavorFailure": [ "The gate mechanism is jammed. Precious seconds lost.", "A shadow falls across the panel. The attempt abandoned.", - "The switch is stuck. The gate stays shut." + "The switch is stuck. The gate stays shut.", + "The gate won't cooperate. Not now.", + "The panel sparks and resets. Try again.", + "Something holds the gate. Not mechanical — worse.", + "The generator noise covers the gate's resistance.", + "The lock mechanism fails. The gate holds.", + "Too slow. The gate doesn't open in time.", + "Pressure on the switch, nothing. Wrong timing." ] }, { @@ -89,12 +159,26 @@ "flavorSuccess": [ "Still as stone. The threat passes without noticing.", "A breath held long — then silence. Safe.", - "The fog swallows the survivor whole. Unseen." + "The fog swallows the survivor whole. Unseen.", + "The patrol route is predictable. Predictable is avoidable.", + "Close. Too close. But not close enough to matter.", + "The survivor becomes scenery. The patrol sees nothing.", + "A narrow gap in the patrol. Taken.", + "The killer's attention is elsewhere. Move.", + "Footsteps near, then receding. Safe.", + "The locker is cold and cramped and perfect." ], "flavorFailure": [ "A twig snaps. Eye contact — then the chase begins.", "The survivor misjudges the angle. Spotted.", - "Heartbeat too loud. Presence too close." + "Heartbeat too loud. Presence too close.", + "The patrol's route changed. A fatal assumption.", + "No cover. The survivor is seen.", + "A shadow where there should be none. Detected.", + "The instinct to run is wrong. It happens anyway.", + "The fog offers no protection here.", + "Spotted. The trial shifts.", + "The killer turns at the wrong moment." ] }, { @@ -105,12 +189,26 @@ "flavorSuccess": [ "Bandages tight, the wound closes. Pain recedes.", "The medkit does its job. The survivor steadies.", - "A few tense minutes — injuries tended." + "A few tense minutes — injuries tended.", + "Not good as new, but functional. Good enough.", + "The bleeding stops. The fog feels slightly less hostile.", + "Clinical precision in a place with no clinics.", + "The medkit is emptied wisely. The injury yields.", + "Hands that shake still heal.", + "The wound is dressed. One less liability.", + "Recovery, however temporary, is still recovery." ], "flavorFailure": [ "Supplies exhausted before the job is done.", "Shaking hands fumble the medkit. Time runs out.", - "The wound is worse than it looked. Supplies fall short." + "The wound is worse than it looked. Supplies fall short.", + "The medkit can't do what it wasn't built for.", + "Partial healing is still an open wound.", + "The medkit empties wrong. Wasted.", + "Not enough supplies. Not enough time.", + "A noise ends the attempt prematurely.", + "The injury resists treatment.", + "The medkit fails the survivor. Move anyway." ] }, { @@ -121,12 +219,26 @@ "flavorSuccess": [ "The pallet crashes down. A moment bought.", "Timber splinters between them. Distance gained.", - "The drop lands true. The chase falters." + "The drop lands true. The chase falters.", + "A calculated throw. The pallet does its job.", + "The killer staggers. A few precious seconds.", + "The pallet drops at the right instant.", + "An obstacle placed. The chase interrupted.", + "The wood holds. That's all it needs to do.", + "A roar of frustration behind the pallet. Run.", + "The drop lands. The gap widens." ], "flavorFailure": [ "The pallet drops wide. No gap created.", "Too slow — the obstacle proves useless.", - "The throw miscalculated. The pursuit continues." + "The throw miscalculated. The pursuit continues.", + "The pallet falls too early. Used for nothing.", + "The killer vaults before the drop lands.", + "The angle was wrong. The pallet wasted.", + "The swing comes before the pallet hits.", + "Not enough distance to matter.", + "The throw panics. The pallet means nothing.", + "A wasted pallet. The chase doesn't stop." ] }, { @@ -137,12 +249,26 @@ "flavorSuccess": [ "The basement yields rare supplies. Worth the risk.", "A pristine toolbox — almost worth dying for.", - "The gamble paid off. The survivor emerges with something valuable." + "The gamble paid off. The survivor emerges with something valuable.", + "The basement gives up its best. A full medkit.", + "Down there and back. The best supplies in the trial.", + "The risk calculates out. Everything needed, found.", + "The stairs back up feel like a victory.", + "The basement held back something good tonight.", + "A rare find. The fog occasionally provides.", + "Against instinct, into the basement. Worth it." ], "flavorFailure": [ "The basement was a trap. Retreat costs dearly.", "The stairwell offers no escape. A mistake made clear.", - "The risk was not worth the reward found — nothing." + "The risk was not worth the reward found — nothing.", + "The basement is empty. And now time is lost.", + "Going down is easy. Coming back is not.", + "The killer knows about the basement.", + "Nothing down here but dread.", + "The search finds nothing. The stairs up feel longer.", + "An empty chest in a dangerous room.", + "The basement takes more than it gives tonight." ] }, { @@ -153,12 +279,326 @@ "flavorSuccess": [ "The hatch sighs open. One last mercy from the fog.", "A sound — familiar, haunting. The hatch, just ahead.", - "Against all odds, the escape route reveals itself." + "Against all odds, the escape route reveals itself.", + "The hatch was always here. It waited.", + "The fog breaks once. That's enough.", + "The survivor finds it before the killer does.", + "One last door. It opens.", + "The hatch glows faintly. Run.", + "The sound of it — an invitation.", + "Found. The trial ends here." ], "flavorFailure": [ "The hatch is nowhere. Only fog and silence.", "Close — so close. Then it closes.", - "The sound was something else entirely." + "The sound was something else entirely.", + "The hatch stays hidden.", + "Searching costs time that isn't there.", + "The killer finds the hatch first.", + "The fog gives nothing freely.", + "The sound led nowhere. A trick of the trial.", + "No hatch. No escape. Not this way.", + "The trial isn't done with the survivor yet." + ] + }, + { + "key": "window_vault", + "baseProbability": 0.60, + "tags": ["escape", "survival"], + "tier": 1, + "flavorSuccess": [ + "The window frame holds. Through and clear.", + "A clean vault. Momentum kept.", + "The opening is tight but usable.", + "Through the gap before the killer closes.", + "The vault buys distance. Use it.", + "A practiced move. The window yields.", + "Landing clean on the other side.", + "The window is a door when you need it.", + "Fast enough. The gap widens.", + "Through. The chase resets." + ], + "flavorFailure": [ + "The window is boarded shut.", + "The vault is too slow. Caught.", + "The frame splinters. Not a clean exit.", + "Misjudged the height. A costly stumble.", + "The window closes the gap but not the killer.", + "Too narrow. No good.", + "The vault fails at the worst moment.", + "The opening was an illusion. Dead end.", + "A bad angle kills the attempt.", + "The window doesn't help tonight." + ] + }, + { + "key": "locker_hide", + "baseProbability": 0.65, + "tags": ["stealth", "survival"], + "tier": 1, + "flavorSuccess": [ + "The locker closes. Footsteps pass. Breathe.", + "Darkness and rust — perfect cover.", + "The killer walks past the locker without a glance.", + "Still. Quiet. Safe.", + "The locker is old and cold and hiding works.", + "In and out before the threat turns around.", + "The locker holds its occupant safe.", + "A long minute of nothing. Then clear.", + "The hiding spot works. The patrol moves on.", + "The locker door stays closed. The killer moves on." + ], + "flavorFailure": [ + "The locker door rattles. Discovered.", + "The killer yanks it open. No hiding here.", + "The locker was already checked.", + "The sound of breathing gives the position away.", + "The killer knows these lockers too well.", + "A squeak of the hinge. Fatal.", + "The hiding spot chosen is the wrong one.", + "The killer's instinct is correct tonight.", + "Spotted entering. No time.", + "The locker offers nothing. Pulled out." + ] + }, + { + "key": "trap_disarm", + "baseProbability": 0.50, + "tags": ["objectives", "altruistic"], + "tier": 2, + "flavorSuccess": [ + "The trap clicks disarmed. One less hazard.", + "Patient work. The mechanism yields.", + "The snap of metal, controlled this time.", + "Disarmed without triggering. Clean.", + "The trap comes apart safely.", + "A moment of focus. The trap poses no more threat.", + "The floor is safer now.", + "The mechanism was elegant. Now it's inert.", + "One trap gone. Onwards.", + "The disarm holds. The path is clear." + ], + "flavorFailure": [ + "The trap snaps. A brutal reminder.", + "One wrong move. The trap wins.", + "The mechanism was subtler than expected.", + "The disarm attempt triggers it.", + "Fingers too slow. The trap bites.", + "The trap was set with someone capable in mind.", + "Miscalculation. The mechanism triggers.", + "The trap holds and catches.", + "The attempt fails. Pain follows.", + "The trap wasn't meant to be disarmed easily." + ] + }, + { + "key": "survivor_rescue", + "baseProbability": 0.45, + "tags": ["altruistic", "hook"], + "tier": 2, + "flavorSuccess": [ + "Off the hook. Both survivors run.", + "The unhook is clean. No one watching.", + "A teammate freed. The trial continues.", + "Quick hands, lucky timing. The rescue holds.", + "The hook releases. Time to move.", + "Pulled free before the killer returns.", + "The rescue works. Move fast.", + "A window of opportunity, taken.", + "The teammate drops free. The trial has two again.", + "Freed. Both running now." + ], + "flavorFailure": [ + "The killer turns back too soon. Retreat.", + "The rescue fails. Both caught.", + "The timing was wrong.", + "The unhook attempted — the killer was watching.", + "No safe window. The rescue abandoned.", + "The hook held on.", + "The approach was spotted.", + "The teammate is hooked again. Worse now.", + "The rescue was a trap.", + "The killer was closer than it looked." + ] + }, + { + "key": "ruin_destroy", + "baseProbability": 0.45, + "tags": ["totem", "objectives", "altruistic"], + "tier": 2, + "flavorSuccess": [ + "The ruin totem falls. Generator speed returns to normal.", + "The hex breaks. The pressure lifts.", + "Ruin destroyed. The generators are finally cooperating.", + "The totem crumbles. Something screams.", + "The ruin is gone. Work can continue properly.", + "The corrupting influence ends here.", + "Ruin undone. A meaningful contribution.", + "The hex shattered. Every generator benefits.", + "Ruin falls. The trial shifts.", + "The cleansing removes one of the fog's advantages." + ], + "flavorFailure": [ + "Ruin holds. The generators resist every effort.", + "The totem is guarded.", + "Ruin endures. Progress is a trickle.", + "The hex is stronger than expected.", + "The cleanse fails. Ruin stays.", + "Guarded or cursed — the totem won't fall.", + "The approach to ruin is cut off.", + "Ruin watches everything. No safe approach.", + "The hex survives the attempt.", + "Ruin stands. The generators punish every mistake." + ] + }, + { + "key": "noise_check", + "baseProbability": 0.70, + "tags": ["stealth", "survival", "objectives"], + "tier": 1, + "flavorSuccess": [ + "The area is clear. Work continues.", + "A careful sweep — nothing here.", + "All quiet. Safe to proceed.", + "The check takes seconds. Worth every one.", + "Nothing threatening nearby. Focus returned.", + "The perimeter is clean. Move on.", + "A slow look around — all clear.", + "The survivor's instincts were right. Safe.", + "No danger. The noise was nothing.", + "The area is empty. Continue." + ], + "flavorFailure": [ + "The check reveals what was feared.", + "Not clear. Danger is close.", + "The area isn't empty.", + "The sound was not nothing.", + "A presence, sensed too late.", + "The check confirms the worst.", + "Not safe. Move now.", + "The area is watched.", + "The noise was a warning.", + "Something is here." + ] + }, + { + "key": "map_find", + "baseProbability": 0.40, + "tags": ["item", "objectives"], + "tier": 1, + "flavorSuccess": [ + "A map of the trial grounds. The generators are marked.", + "Routes, exits — everything useful on one piece of paper.", + "The map provides context. Decisions improve.", + "A schematic of the realm. Useful.", + "The map shows what couldn't be known otherwise.", + "A rare find. The trial becomes navigable.", + "Totem locations, hooks — the map tells all.", + "The map is accurate. That's not guaranteed here.", + "A clearer picture of the trial grounds.", + "The map is old but correct. It will help." + ], + "flavorFailure": [ + "Nothing useful found.", + "The map, if it existed, is gone.", + "The search yields no information.", + "Fog and confusion. No map here.", + "The grounds give nothing away.", + "The trial keeps its layout to itself.", + "No orientation gained.", + "Blind as before.", + "The realm offers no guide tonight.", + "No map. Trust instinct." + ] + }, + { + "key": "scratch_marks", + "baseProbability": 0.55, + "tags": ["stealth", "altruistic"], + "tier": 1, + "flavorSuccess": [ + "The trail read correctly. The survivor is found.", + "Scratch marks, followed. Another survivor located.", + "The signs in the fog tell a story.", + "The trail is fresh. Someone was here recently.", + "The marks lead somewhere useful.", + "Reading the ground pays off.", + "The scratch marks don't lie.", + "A teammate's path, followed to its source.", + "The signs are legible. The survivor is found.", + "The trail ends at something worth finding." + ], + "flavorFailure": [ + "The trail goes cold.", + "The marks lead nowhere useful.", + "Misread. The path was wrong.", + "The trail circles back. Disorienting.", + "The signs in the fog are misleading.", + "Too old. The trail is gone.", + "The scratch marks weren't recent.", + "The trail led somewhere dangerous.", + "The signs can't be read clearly.", + "The trail ends without resolution." + ] + }, + { + "key": "bone_chill", + "baseProbability": 0.35, + "tags": ["high-risk", "survival"], + "tier": 3, + "flavorSuccess": [ + "The presence recedes. The survivor is not chosen.", + "Whatever stalks the fog passes over.", + "The terror subsides. Not tonight.", + "The entity considers — then moves on.", + "The survivor is overlooked. Lucky.", + "The cold lifts. The entity chose elsewhere.", + "The fog's attention moves away.", + "Stillness. The presence is gone.", + "Not taken. Not yet.", + "The chill passes. The trial continues." + ], + "flavorFailure": [ + "The fog thickens. Something approaches.", + "The presence locks on. There is no outrunning this.", + "The entity's attention does not waver.", + "Noticed. The trial changes.", + "The chill deepens. The entity is here.", + "No escape from something this deliberate.", + "The fog moves with purpose.", + "The survivor is seen by something that doesn't forget.", + "The presence descends.", + "Chosen. The hardest part begins." + ] + }, + { + "key": "killer_instinct", + "baseProbability": 0.30, + "tags": ["high-risk", "stealth"], + "tier": 3, + "flavorSuccess": [ + "The instinct passes harmlessly. Not found.", + "The killer's sense of where to look is wrong tonight.", + "The prediction fails. Safe.", + "The killer checks elsewhere.", + "Instinct misfires. The survivor is still hidden.", + "Wrong corner checked. The survivor breathes.", + "The killer's certainty was misplaced.", + "Not there. Not tonight.", + "The instinct was wrong. Move quickly.", + "The killer's focus breaks elsewhere." + ], + "flavorFailure": [ + "The killer knows exactly where to look.", + "Instinct and experience. The survivor is found.", + "No hiding from something this certain.", + "The killer's read was correct.", + "The position is given away before the search begins.", + "Exactly where expected. No luck tonight.", + "The killer's certainty was earned.", + "Found before the search starts.", + "The instinct is right.", + "The killer walks directly to the survivor." ] } ] diff --git a/libs/mission-logic/src/index.ts b/libs/mission-logic/src/index.ts index 8b54e17..f461982 100644 --- a/libs/mission-logic/src/index.ts +++ b/libs/mission-logic/src/index.ts @@ -1 +1,2 @@ export * from './lib/encounter-resolver'; +export * from './lib/perk-math'; diff --git a/libs/mission-logic/src/lib/encounter-resolver.spec.ts b/libs/mission-logic/src/lib/encounter-resolver.spec.ts index a023626..70dddc2 100644 --- a/libs/mission-logic/src/lib/encounter-resolver.spec.ts +++ b/libs/mission-logic/src/lib/encounter-resolver.spec.ts @@ -198,6 +198,69 @@ describe('resolveEncounter', () => { expect(result.logText.length).toBeGreaterThan(0); } }); + + // Edge cases: modifier overflow / degenerate inputs + it('massive positive perk modifier clamps probability to 1 — never throws', () => { + const perk: Perk = { + id: '00000000-0000-4000-a000-000000000003', + key: 'overpowered', + name: '', + description: '', + tags: [], + modifiers: [{ target: 'successChance', type: 'additive', amount: 100 }], + }; + const result = resolveEncounter( + makeInput({ encounter: { key: 'gen', baseProbability: 0.5, tags: [] }, + survivor: { id: '00000000-0000-4000-a000-000000000002', state: 'active', + stats: { objectives: 5, survival: 5, altruism: 5 }, perkSlots: [perk], hookCount: 0 } }) + ); + expect(result.success).toBe(true); // clamped to 1 → always succeeds + }); + + it('massive negative perk modifier clamps probability to 0 — never throws', () => { + const perk: Perk = { + id: '00000000-0000-4000-a000-000000000003', + key: 'cursed', + name: '', + description: '', + tags: [], + modifiers: [{ target: 'successChance', type: 'additive', amount: -100 }], + }; + const result = resolveEncounter( + makeInput({ encounter: { key: 'gen', baseProbability: 0.5, tags: [] }, + survivor: { id: '00000000-0000-4000-a000-000000000002', state: 'active', + stats: { objectives: 5, survival: 5, altruism: 5 }, perkSlots: [perk], hookCount: 0 } }) + ); + expect(result.success).toBe(false); // clamped to 0 → always fails + }); + + it('empty perkSlots array is handled without error', () => { + expect(() => + resolveEncounter(makeInput({ survivor: { + id: '00000000-0000-4000-a000-000000000002', state: 'active', + stats: { objectives: 5, survival: 5, altruism: 5 }, perkSlots: [], hookCount: 0, + } })) + ).not.toThrow(); + }); + + it('sacrificed survivor produces no state change on failure', () => { + const result = resolveEncounter(makeInput({ + encounter: { key: 'gen', baseProbability: 0, tags: [] }, + survivor: { id: '00000000-0000-4000-a000-000000000002', state: 'sacrificed', + stats: { objectives: 1, survival: 1, altruism: 1 }, perkSlots: [], hookCount: 2 }, + })); + expect(result.success).toBe(false); + expect(result.survivorStateChange).toBeNull(); + }); + + it('idle survivor produces no state change on failure', () => { + const result = resolveEncounter(makeInput({ + encounter: { key: 'gen', baseProbability: 0, tags: [] }, + survivor: { id: '00000000-0000-4000-a000-000000000002', state: 'idle', + stats: { objectives: 1, survival: 1, altruism: 1 }, perkSlots: [], hookCount: 0 }, + })); + expect(result.survivorStateChange).toBeNull(); + }); }); // ── Property-based tests ────────────────────────────────────────────────────── diff --git a/libs/mission-logic/src/lib/encounter-resolver.ts b/libs/mission-logic/src/lib/encounter-resolver.ts index 9055121..b30d6a3 100644 --- a/libs/mission-logic/src/lib/encounter-resolver.ts +++ b/libs/mission-logic/src/lib/encounter-resolver.ts @@ -2,11 +2,11 @@ import seedrandom = require('seedrandom'); import type { EncounterDefinition, EncounterResult, - ModifierApplication, Perk, SurvivorState, SurvivorStats, } from '@fog-explorer/api-interfaces'; +import { applyPerkModifiers } from './perk-math'; export interface ResolverInput { seed: string; @@ -23,15 +23,10 @@ export interface ResolverInput { }; } -// Probability adjustments per stat point and per difficulty level. const OBJECTIVES_BONUS = 0.02; const ALTRUISM_BONUS = 0.02; const DIFFICULTY_PENALTY = 0.12; - -// Tag that enables the altruism stat bonus. const ALTRUISM_TAG = 'altruistic'; - -// Injury probability floor/ceiling when active survivor fails. const INJURY_CHANCE_BASE = 0.8; const INJURY_CHANCE_FLOOR = 0.2; const SURVIVAL_INJURY_REDUCTION = 0.05; @@ -40,25 +35,28 @@ export function resolveEncounter(input: ResolverInput): EncounterResult { const rng = seedrandom(input.seed); let probability = input.encounter.baseProbability; - probability -= (input.difficulty - 1) * DIFFICULTY_PENALTY; probability += input.survivor.stats.objectives * OBJECTIVES_BONUS; - if (input.encounter.tags.includes(ALTRUISM_TAG)) { probability += input.survivor.stats.altruism * ALTRUISM_BONUS; } - const modifiersApplied = applyPerkModifiers(probability, input); - probability = modifiersApplied.adjusted; - const appliedList = modifiersApplied.applied; + const { probability: finalProbability, applied } = applyPerkModifiers( + probability, + input.survivor.perkSlots, + input.encounter.tags + ); - probability = Math.max(0, Math.min(1, probability)); - - const success = rng() < probability; + const success = rng() < finalProbability; const survivorStateChange = success ? null - : computeStateChange(rng, input.survivor.state, input.survivor.stats.survival, input.survivor.hookCount); + : computeStateChange( + rng, + input.survivor.state, + input.survivor.stats.survival, + input.survivor.hookCount + ); return { missionId: input.missionId, @@ -68,47 +66,11 @@ export function resolveEncounter(input: ResolverInput): EncounterResult { seed: input.seed, success, survivorStateChange, - modifiersApplied: appliedList, + modifiersApplied: applied, logText: buildLogText(input.encounter.key, success, survivorStateChange), }; } -function applyPerkModifiers( - startProbability: number, - input: ResolverInput -): { adjusted: number; applied: ModifierApplication[] } { - let probability = startProbability; - const applied: ModifierApplication[] = []; - - for (const perk of input.survivor.perkSlots) { - for (const mod of perk.modifiers) { - if (mod.target !== 'successChance') continue; - - if (mod.condition) { - const tagMatch = mod.condition.encounterTags.some((t) => - input.encounter.tags.includes(t) - ); - if (!tagMatch) continue; - } - - if (mod.type === 'additive') { - probability += mod.amount; - } else { - probability *= 1 + mod.amount; - } - - applied.push({ - perkKey: perk.key, - target: mod.target, - type: mod.type, - effectiveAmount: mod.amount, - }); - } - } - - return { adjusted: probability, applied }; -} - function computeStateChange( rng: seedrandom.PRNG, currentState: SurvivorState, diff --git a/libs/mission-logic/src/lib/monte-carlo.spec.ts b/libs/mission-logic/src/lib/monte-carlo.spec.ts new file mode 100644 index 0000000..5f69d88 --- /dev/null +++ b/libs/mission-logic/src/lib/monte-carlo.spec.ts @@ -0,0 +1,305 @@ +import { resolveEncounter } from './encounter-resolver'; +import type { + EncounterDefinition, + Perk, + SurvivorState, + SurvivorStats, +} from '@fog-explorer/api-interfaces'; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const TICKS_PER_MISSION = 20; +const MISSIONS_PER_SCENARIO = 2000; // 5 scenarios × 2000 = 10 000 total + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface SimScenario { + readonly name: string; + readonly difficulty: number; + readonly encounter: EncounterDefinition; + readonly stats: SurvivorStats; + readonly perks: Perk[]; +} + +interface MissionOutcome { + readonly successCount: number; + readonly wasInjured: boolean; + readonly wasSacrificed: boolean; +} + +interface DistributionSnapshot { + successRate: number; + injuryRate: number; + sacrificeRate: number; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makePerk( + key: string, + amount: number, + type: 'additive' | 'multiplicative' +): Perk { + return { + id: '00000000-0000-4000-a000-000000000001', + key, + name: key, + description: '', + tags: [], + modifiers: [{ target: 'successChance', type, amount }], + }; +} + +function simulateMission( + scenario: SimScenario, + missionIndex: number +): MissionOutcome { + let state: SurvivorState = 'active'; + let hookCount = 0; + let successCount = 0; + let wasInjured = false; + let wasSacrificed = false; + + for (let tick = 0; tick < TICKS_PER_MISSION; tick++) { + const result = resolveEncounter({ + seed: `mc-${scenario.name}-${missionIndex}-${tick}`, + missionId: `mc-mission-${missionIndex}`, + tickIndex: tick, + difficulty: scenario.difficulty, + encounter: scenario.encounter, + survivor: { + id: '00000000-0000-4000-a000-000000000002', + state, + stats: scenario.stats, + perkSlots: scenario.perks, + hookCount, + }, + }); + + if (result.survivorStateChange) { + const sc = result.survivorStateChange; + if (sc.to === 'injured') { + state = 'injured'; + wasInjured = true; + } else if (sc.to === 'downed') { + // Stay downed for the next tick so downed+hookCount≥2 → sacrifice can fire. + hookCount++; + state = 'downed'; + wasInjured = true; + } else if (sc.to === 'sacrificed') { + wasSacrificed = true; + break; + } + } else { + // No state change: success, or failure with no consequence (e.g. downed+hookCount<2). + // If currently downed (and not sacrificed), the encounter resolves without harm — rescued. + if (state === 'downed') { + state = 'active'; + } + if (result.success) { + successCount++; + } + } + } + + return { successCount, wasInjured, wasSacrificed }; +} + +function runScenario(scenario: SimScenario): DistributionSnapshot { + let totalSuccess = 0; + let injuredCount = 0; + let sacrificedCount = 0; + + for (let i = 0; i < MISSIONS_PER_SCENARIO; i++) { + const { successCount, wasInjured, wasSacrificed } = simulateMission( + scenario, + i + ); + totalSuccess += successCount; + if (wasInjured) injuredCount++; + if (wasSacrificed) sacrificedCount++; + } + + const round = (n: number): number => Math.round(n * 1000) / 1000; + return { + successRate: round(totalSuccess / (MISSIONS_PER_SCENARIO * TICKS_PER_MISSION)), + injuryRate: round(injuredCount / MISSIONS_PER_SCENARIO), + sacrificeRate: round(sacrificedCount / MISSIONS_PER_SCENARIO), + }; +} + +// ── Scenarios ───────────────────────────────────────────────────────────────── + +const STANDARD_ENCOUNTER: EncounterDefinition = { + key: 'generator', + baseProbability: 0.6, + tags: [], +}; + +const BASE_STATS: SurvivorStats = { objectives: 5, survival: 5, altruism: 5 }; + +const SCENARIOS = { + baseline: { + name: 'baseline', + difficulty: 1, + encounter: STANDARD_ENCOUNTER, + stats: BASE_STATS, + perks: [], + }, + hard: { + name: 'hard', + difficulty: 3, + encounter: STANDARD_ENCOUNTER, + stats: BASE_STATS, + perks: [], + }, + objectivesPerk: { + name: 'objectives-perk', + difficulty: 1, + encounter: STANDARD_ENCOUNTER, + stats: BASE_STATS, + perks: [makePerk('objectives-boost', 0.15, 'additive')], + }, + survivalPerk: { + name: 'survival-perk', + difficulty: 1, + encounter: STANDARD_ENCOUNTER, + stats: { ...BASE_STATS, survival: 10 }, + perks: [], + }, + stacked: { + // objectives=7 → +0.14, add +0.05, mul ×1.10 → ~87% final probability. + // Represents a well-optimised build that is strong but not degenerate. + name: 'stacked', + difficulty: 1, + encounter: STANDARD_ENCOUNTER, + stats: { objectives: 7, survival: 7, altruism: 5 }, + perks: [ + makePerk('add-boost', 0.05, 'additive'), + makePerk('mul-boost', 0.1, 'multiplicative'), + ], + }, +} satisfies Record; + +// ── Distribution snapshot tests ─────────────────────────────────────────────── +// Seeds are deterministic strings → results are reproducible across runs. +// On first run, vitest writes the inline snapshots; CI then guards regressions. + +describe('Monte Carlo balance harness — distribution snapshots', () => { + let results: Record; + + beforeAll(() => { + results = { + baseline: runScenario(SCENARIOS.baseline), + hard: runScenario(SCENARIOS.hard), + objectivesPerk: runScenario(SCENARIOS.objectivesPerk), + survivalPerk: runScenario(SCENARIOS.survivalPerk), + stacked: runScenario(SCENARIOS.stacked), + }; + }); + + it('baseline distribution', () => { + expect(results.baseline).toMatchInlineSnapshot(` + { + "injuryRate": 0.973, + "sacrificeRate": 0.194, + "successRate": 0.67, + } + `); + }); + + it('hard difficulty distribution', () => { + expect(results.hard).toMatchInlineSnapshot(` + { + "injuryRate": 0.999, + "sacrificeRate": 0.72, + "successRate": 0.341, + } + `); + }); + + it('objectives-perk distribution', () => { + expect(results.objectivesPerk).toMatchInlineSnapshot(` + { + "injuryRate": 0.815, + "sacrificeRate": 0.018, + "successRate": 0.849, + } + `); + }); + + it('survival-perk distribution', () => { + expect(results.survivalPerk).toMatchInlineSnapshot(` + { + "injuryRate": 0.846, + "sacrificeRate": 0.093, + "successRate": 0.686, + } + `); + }); + + it('stacked perk loadout distribution', () => { + expect(results.stacked).toMatchInlineSnapshot(` + { + "injuryRate": 0.711, + "sacrificeRate": 0.007, + "successRate": 0.866, + } + `); + }); +}); + +// ── Balance envelope assertions ─────────────────────────────────────────────── +// These fire on top of snapshots to catch degenerate configurations even if +// a snapshot was accidentally committed with broken numbers. + +describe('Monte Carlo balance harness — balance envelopes', () => { + let results: Record; + + beforeAll(() => { + results = { + baseline: runScenario(SCENARIOS.baseline), + hard: runScenario(SCENARIOS.hard), + objectivesPerk: runScenario(SCENARIOS.objectivesPerk), + survivalPerk: runScenario(SCENARIOS.survivalPerk), + stacked: runScenario(SCENARIOS.stacked), + }; + }); + + it('hard difficulty has lower success rate than baseline', () => { + expect(results.hard.successRate).toBeLessThan(results.baseline.successRate); + }); + + it('hard difficulty has higher sacrifice rate than baseline', () => { + expect(results.hard.sacrificeRate).toBeGreaterThan( + results.baseline.sacrificeRate + ); + }); + + it('objectives perk increases success rate over baseline', () => { + expect(results.objectivesPerk.successRate).toBeGreaterThan( + results.baseline.successRate + ); + }); + + it('stacked loadout does not produce degenerate success rate (>0.95)', () => { + expect(results.stacked.successRate).toBeLessThan(0.95); + }); + + it('baseline sacrifice rate is meaningful (>1%) and not excessive (<25%)', () => { + expect(results.baseline.sacrificeRate).toBeGreaterThan(0.01); + expect(results.baseline.sacrificeRate).toBeLessThan(0.25); + }); + + it('every scenario produces some successes (not locked out)', () => { + for (const dist of Object.values(results)) { + expect(dist.successRate).toBeGreaterThan(0); + } + }); + + it('every scenario has a chance of injury (game has stakes)', () => { + for (const dist of Object.values(results)) { + expect(dist.injuryRate).toBeGreaterThan(0); + } + }); +}); diff --git a/libs/mission-logic/src/lib/perk-math.spec.ts b/libs/mission-logic/src/lib/perk-math.spec.ts new file mode 100644 index 0000000..25a4ea2 --- /dev/null +++ b/libs/mission-logic/src/lib/perk-math.spec.ts @@ -0,0 +1,225 @@ +import * as fc from 'fast-check'; +import { applyPerkModifiers } from './perk-math'; +import type { Perk } from '@fog-explorer/api-interfaces'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makePerk(overrides: Partial & { amount: number; type?: 'additive' | 'multiplicative'; tags?: string[] }): Perk { + return { + id: '00000000-0000-4000-a000-000000000001', + key: overrides.key ?? 'test-perk', + name: 'Test Perk', + description: '', + tags: [], + modifiers: [ + { + target: 'successChance', + type: overrides.type ?? 'additive', + amount: overrides.amount, + condition: overrides.tags ? { encounterTags: overrides.tags } : undefined, + }, + ], + }; +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +describe('applyPerkModifiers', () => { + it('returns baseProbability unchanged with no perks', () => { + const { probability, applied } = applyPerkModifiers(0.5, [], []); + expect(probability).toBe(0.5); + expect(applied).toHaveLength(0); + }); + + it('applies a single additive modifier', () => { + const { probability } = applyPerkModifiers(0.4, [makePerk({ amount: 0.2 })], []); + expect(probability).toBeCloseTo(0.6); + }); + + it('applies a single multiplicative modifier', () => { + const { probability } = applyPerkModifiers(0.5, [makePerk({ amount: 0.2, type: 'multiplicative' })], []); + expect(probability).toBeCloseTo(0.6); + }); + + it('additives sum before multiplicatives apply', () => { + const perks: Perk[] = [ + makePerk({ key: 'a', amount: 0.1 }), // additive +0.1 + makePerk({ key: 'b', amount: 0.1 }), // additive +0.1 + makePerk({ key: 'c', amount: 0.5, type: 'multiplicative' }), // ×1.5 + ]; + // base 0.4 + 0.2 additive = 0.6 → × 1.5 = 0.9 + const { probability } = applyPerkModifiers(0.4, perks, []); + expect(probability).toBeCloseTo(0.9); + }); + + it('clamps overflow above 1.0 to 1.0', () => { + const perks = [makePerk({ amount: 5.0 })]; // base 0.5 + 5 = 5.5 → clamped + const { probability } = applyPerkModifiers(0.5, perks, []); + expect(probability).toBe(1); + }); + + it('clamps underflow below 0 to 0', () => { + const perks = [makePerk({ amount: -5.0 })]; + const { probability } = applyPerkModifiers(0.5, perks, []); + expect(probability).toBe(0); + }); + + it('negative multiplicative modifier reduces probability', () => { + const { probability } = applyPerkModifiers(0.6, [makePerk({ amount: -0.5, type: 'multiplicative' })], []); + expect(probability).toBeCloseTo(0.3); + }); + + it('skips modifier when condition tag not in encounter tags', () => { + const perk = makePerk({ amount: 0.3, tags: ['totem'] }); + const { probability, applied } = applyPerkModifiers(0.5, [perk], ['generator']); + expect(probability).toBeCloseTo(0.5); + expect(applied).toHaveLength(0); + }); + + it('applies modifier when condition tag matches', () => { + const perk = makePerk({ amount: 0.3, tags: ['generator'] }); + const { probability } = applyPerkModifiers(0.5, [perk], ['generator', 'objectives']); + expect(probability).toBeCloseTo(0.8); + }); + + it('unconditional modifier applies regardless of encounter tags', () => { + const perk = makePerk({ amount: 0.2 }); // no condition + const { probability } = applyPerkModifiers(0.5, [perk], []); + expect(probability).toBeCloseTo(0.7); + }); + + it('non-successChance target modifiers are ignored', () => { + const perk: Perk = { + id: '00000000-0000-4000-a000-000000000001', + key: 'stat-perk', + name: '', + description: '', + tags: [], + modifiers: [{ target: 'objectives', type: 'additive', amount: 2 }], + }; + const { probability } = applyPerkModifiers(0.5, [perk], []); + expect(probability).toBe(0.5); + }); + + it('records one applied entry per modifier', () => { + const perks = [ + makePerk({ key: 'a', amount: 0.1 }), + makePerk({ key: 'b', amount: 0.2 }), + ]; + const { applied } = applyPerkModifiers(0.4, perks, []); + expect(applied).toHaveLength(2); + expect(applied.map((a) => a.perkKey)).toEqual(expect.arrayContaining(['a', 'b'])); + }); + + it('applied entries record preApply and postApply probabilities', () => { + const perk = makePerk({ amount: 0.2 }); + const { applied } = applyPerkModifiers(0.4, [perk], []); + expect(applied[0].preApplyProbability).toBeCloseTo(0.4); + expect(applied[0].postApplyProbability).toBeCloseTo(0.6); + }); +}); + +// ── Property-based tests ────────────────────────────────────────────────────── + +describe('applyPerkModifiers — property-based', () => { + const arbAmount = fc.float({ min: Math.fround(-1), max: Math.fround(1), noNaN: true }); + + const arbPerk: fc.Arbitrary = fc.record({ + id: fc.uuid(), + key: fc.string({ minLength: 1, maxLength: 20 }), + name: fc.string({ minLength: 1 }), + description: fc.string(), + tags: fc.constant([]), + modifiers: fc.array( + fc.record({ + target: fc.constantFrom('successChance' as const), + type: fc.constantFrom('additive' as const, 'multiplicative' as const), + amount: arbAmount, + condition: fc.option( + fc.record({ encounterTags: fc.array(fc.constantFrom('generator', 'totem'), { minLength: 1 }) }), + { nil: undefined } + ), + }), + { maxLength: 3 } + ), + }); + + it('result probability is always in [0, 1]', () => { + fc.assert( + fc.property( + fc.float({ min: 0, max: 1, noNaN: true }), + fc.array(arbPerk, { maxLength: 4 }), + fc.array(fc.constantFrom('generator', 'totem', 'exit'), { maxLength: 3 }), + (base, perks, tags) => { + const { probability } = applyPerkModifiers(base, perks, tags); + expect(probability).toBeGreaterThanOrEqual(0); + expect(probability).toBeLessThanOrEqual(1); + } + ) + ); + }); + + it('every applied modifier references a perk in the input', () => { + fc.assert( + fc.property( + fc.float({ min: 0, max: 1, noNaN: true }), + fc.array(arbPerk, { maxLength: 4 }), + (base, perks) => { + const { applied } = applyPerkModifiers(base, perks, []); + const keys = new Set(perks.map((p) => p.key)); + for (const mod of applied) { + expect(keys.has(mod.perkKey)).toBe(true); + } + } + ) + ); + }); + + it('no perks never changes base probability (within [0,1])', () => { + fc.assert( + fc.property(fc.float({ min: 0, max: 1, noNaN: true }), (base) => { + const { probability, applied } = applyPerkModifiers(base, [], []); + expect(probability).toBeCloseTo(base); + expect(applied).toHaveLength(0); + }) + ); + }); + + it('additive modifiers with large positive amounts still clamp to 1', () => { + fc.assert( + fc.property( + fc.float({ min: 0, max: 1, noNaN: true }), + fc.array( + fc.float({ min: Math.fround(0.1), max: Math.fround(10), noNaN: true }), + { minLength: 1, maxLength: 5 } + ), + (base, amounts) => { + const perks = amounts.map((amount, i) => + makePerk({ key: `p${i}`, amount }) + ); + const { probability } = applyPerkModifiers(base, perks, []); + expect(probability).toBeLessThanOrEqual(1); + } + ) + ); + }); + + it('additive modifiers with large negative amounts still clamp to 0', () => { + fc.assert( + fc.property( + fc.float({ min: 0, max: 1, noNaN: true }), + fc.array( + fc.float({ min: Math.fround(-10), max: Math.fround(-0.1), noNaN: true }), + { minLength: 1, maxLength: 5 } + ), + (base, amounts) => { + const perks = amounts.map((amount, i) => + makePerk({ key: `p${i}`, amount }) + ); + const { probability } = applyPerkModifiers(base, perks, []); + expect(probability).toBeGreaterThanOrEqual(0); + } + ) + ); + }); +}); diff --git a/libs/mission-logic/src/lib/perk-math.ts b/libs/mission-logic/src/lib/perk-math.ts new file mode 100644 index 0000000..111a1c3 --- /dev/null +++ b/libs/mission-logic/src/lib/perk-math.ts @@ -0,0 +1,101 @@ +import type { ModifierApplication, Perk, PerkModifierTarget } from '@fog-explorer/api-interfaces'; + +export interface AppliedModifier extends ModifierApplication { + readonly preApplyProbability: number; + readonly postApplyProbability: number; +} + +export interface PerkMathResult { + readonly probability: number; + readonly applied: AppliedModifier[]; +} + +const P_MIN = 0; +const P_MAX = 1; + +/** + * Applies all perk modifiers targeting `successChance` from the given perk + * slots, filtered by encounter tags. Additives are summed first, then + * multiplicatives applied in order. Result is clamped to [0, 1]. + * + * Separating this from the resolver keeps the probability pipeline testable + * independently and makes modifier-overflow edge cases visible. + */ +export function applyPerkModifiers( + baseProbability: number, + perks: Perk[], + encounterTags: string[], + target: PerkModifierTarget = 'successChance' +): PerkMathResult { + let probability = baseProbability; + const applied: AppliedModifier[] = []; + + // Two-pass: collect additive sum, then apply multiplicatives. + // This matches standard RPG convention: add-then-multiply prevents + // multiplicative stacking from compounding excessively. + let additiveSum = 0; + const multiplicativePerks: Array<{ perk: Perk; amount: number }> = []; + + for (const perk of perks) { + for (const mod of perk.modifiers) { + if (mod.target !== target) continue; + if (!conditionMatches(mod.condition, encounterTags)) continue; + + if (mod.type === 'additive') { + additiveSum += mod.amount; + } else { + multiplicativePerks.push({ perk, amount: mod.amount }); + } + } + } + + // Apply additive sum in one step. + if (additiveSum !== 0) { + const pre = probability; + probability += additiveSum; + + // Record one entry per contributing perk. + for (const perk of perks) { + for (const mod of perk.modifiers) { + if (mod.target !== target || mod.type !== 'additive') continue; + if (!conditionMatches(mod.condition, encounterTags)) continue; + applied.push({ + perkKey: perk.key, + target: mod.target, + type: mod.type, + effectiveAmount: mod.amount, + preApplyProbability: pre, + postApplyProbability: probability, + }); + } + } + } + + // Apply multiplicatives individually so each can be logged. + for (const { perk, amount } of multiplicativePerks) { + const pre = probability; + probability *= 1 + amount; + applied.push({ + perkKey: perk.key, + target, + type: 'multiplicative', + effectiveAmount: amount, + preApplyProbability: pre, + postApplyProbability: probability, + }); + } + + return { probability: clamp(probability, P_MIN, P_MAX), applied }; +} + +function conditionMatches( + condition: { encounterTags: string[] } | undefined, + encounterTags: string[] +): boolean { + if (!condition) return true; + return condition.encounterTags.some((t) => encounterTags.includes(t)); +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} diff --git a/package.json b/package.json index e50407c..880d928 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "jsonc-eslint-parser": "^2.1.0", "nx": "22.7.1", "prettier": "~3.6.2", + "prisma": "^5.22.0", "ts-jest": "^29.4.0", "ts-node": "10.9.1", "tslib": "^2.3.0", @@ -72,11 +73,29 @@ "@nestjs/core": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "@nestjs/schedule": "^6.1.3", + "@nestjs/throttler": "^6.5.0", + "@prisma/client": "^5.22.0", "axios": "^1.6.0", "ioredis": "^5.10.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", "seedrandom": "^3.0.5", "zod": "^4.4.3" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "@nestjs/core", + "@parcel/watcher", + "@prisma/client", + "@prisma/engines", + "@swc/core", + "esbuild", + "less", + "lmdb", + "msgpackr-extract", + "nx", + "prisma", + "unrs-resolver" + ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a33d8ec..ed5b7a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,12 @@ importers: '@nestjs/schedule': specifier: ^6.1.3 version: 6.1.3(@nestjs/common@11.1.19(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.19) + '@nestjs/throttler': + specifier: ^6.5.0 + version: 6.5.0(@nestjs/common@11.1.19(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.1.14) + '@prisma/client': + specifier: ^5.22.0 + version: 5.22.0(prisma@5.22.0) axios: specifier: ^1.6.0 version: 1.15.0 @@ -198,6 +204,9 @@ importers: prettier: specifier: ~3.6.2 version: 3.6.2 + prisma: + specifier: ^5.22.0 + version: 5.22.0 ts-jest: specifier: ^29.4.0 version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(esbuild@0.27.3)(jest-util@30.3.0)(jest@30.3.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@20.19.9)(typescript@5.9.3)))(typescript@5.9.3) @@ -2188,6 +2197,13 @@ packages: '@nestjs/platform-express': optional: true + '@nestjs/throttler@6.5.0': + resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + '@noble/hashes@1.4.0': resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} engines: {node: '>= 16'} @@ -2661,6 +2677,30 @@ packages: engines: {node: '>=18'} hasBin: true + '@prisma/client@5.22.0': + resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} + engines: {node: '>=16.13'} + peerDependencies: + prisma: '*' + peerDependenciesMeta: + prisma: + optional: true + + '@prisma/debug@5.22.0': + resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': + resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==} + + '@prisma/engines@5.22.0': + resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==} + + '@prisma/fetch-engine@5.22.0': + resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==} + + '@prisma/get-platform@5.22.0': + resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==} + '@rolldown/binding-android-arm64@1.0.0-rc.17': resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6637,6 +6677,11 @@ packages: resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + prisma@5.22.0: + resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==} + engines: {node: '>=16.13'} + hasBin: true + proc-log@6.1.0: resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} engines: {node: ^20.17.0 || >=22.9.0} @@ -10398,6 +10443,12 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.19) + '@nestjs/throttler@6.5.0(@nestjs/common@11.1.19(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.1.14)': + dependencies: + '@nestjs/common': 11.1.19(reflect-metadata@0.1.14)(rxjs@7.8.2) + '@nestjs/core': 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.1.14)(rxjs@7.8.2) + reflect-metadata: 0.1.14 + '@noble/hashes@1.4.0': {} '@npmcli/agent@4.0.0': @@ -11250,6 +11301,31 @@ snapshots: dependencies: playwright: 1.59.1 + '@prisma/client@5.22.0(prisma@5.22.0)': + optionalDependencies: + prisma: 5.22.0 + + '@prisma/debug@5.22.0': {} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {} + + '@prisma/engines@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/fetch-engine': 5.22.0 + '@prisma/get-platform': 5.22.0 + + '@prisma/fetch-engine@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/get-platform': 5.22.0 + + '@prisma/get-platform@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@rolldown/binding-android-arm64@1.0.0-rc.17': optional: true @@ -15648,6 +15724,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + prisma@5.22.0: + dependencies: + '@prisma/engines': 5.22.0 + optionalDependencies: + fsevents: 2.3.3 + proc-log@6.1.0: {} process-nextick-args@2.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9f45815..d19b9c2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,21 +1,25 @@ allowBuilds: '@nestjs/core': true '@parcel/watcher': true + '@prisma/engines': true '@swc/core': true esbuild: true less: true lmdb: true msgpackr-extract: true nx: true + prisma: true unrs-resolver: true autoInstallPeers: true onlyBuiltDependencies: - '@nestjs/core' - '@parcel/watcher' + - '@prisma/engines' - '@swc/core' - esbuild - less - lmdb - msgpackr-extract - nx + - prisma - unrs-resolver