Stage 5: Add Prisma integration and enhance mission management

- Introduced Prisma as a dependency in package.json and updated pnpm-lock.yaml.
- Created Prisma module and service for database interactions.
- Added initial Prisma schema and migration for user, survivor, mission, and related entities.
- Implemented throttling in the API using @nestjs/throttler for rate limiting.
- Enhanced mission management logic to utilize Prisma for database transactions.
- Updated missions controller and service to handle mission state and participant management.
- Added Twitch PubSub service for real-time updates on mission states.
This commit is contained in:
Maurycy
2026-05-07 15:42:52 +00:00
parent e8523d270e
commit 21f1a5319f
22 changed files with 1676 additions and 96 deletions

View File

@@ -2,6 +2,7 @@
"permissions": {
"allow": [
"Bash(node -e *)",
"Bash(python3 *)",
"Bash(node --input-type=module)",
"Bash(2>&1)",
"Bash(pnpm *)"

View File

@@ -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;

View File

@@ -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")
}

View File

@@ -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 {}

View File

@@ -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<MissionStateResponse> {
@@ -38,9 +40,9 @@ export class MissionsController {
async startMission(
@TwitchClaims() claims: TwitchJwtPayload,
@Body() body: unknown
): Promise<MissionStateResponse> {
): Promise<NonNullable<MissionStateResponse>> {
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);

View File

@@ -15,6 +15,6 @@ import { MissionsService } from './missions.service';
EncounterService,
GroupSynergyService,
],
exports: [MissionStoreService, EncounterService],
exports: [MissionStoreService, EncounterService, GroupSynergyService],
})
export class MissionsModule {}

View File

@@ -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<NonNullable<MissionStateResponse>> {
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)}`;
}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -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<void> {
await this.$connect();
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}

View File

@@ -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 {}

View File

@@ -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<void> {
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<string | null> {
const mission = await this.prisma.mission.findUnique({
where: { id: missionId },
select: { channelId: true },
});
return mission?.channelId ?? null;
}
}

View File

@@ -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<MissionStateResponse>
): Promise<void> {
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}`;
}

View File

@@ -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."
]
}
]

View File

@@ -1 +1,2 @@
export * from './lib/encounter-resolver';
export * from './lib/perk-math';

View File

@@ -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 ──────────────────────────────────────────────────────

View File

@@ -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,

View File

@@ -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<string, SimScenario>;
// ── 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<keyof typeof SCENARIOS, DistributionSnapshot>;
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<keyof typeof SCENARIOS, DistributionSnapshot>;
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);
}
});
});

View File

@@ -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<Perk> & { 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<Perk> = 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);
}
)
);
});
});

View File

@@ -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));
}

View File

@@ -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"
]
}
}

82
pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -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