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:
@@ -2,6 +2,7 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(node -e *)",
|
"Bash(node -e *)",
|
||||||
|
"Bash(python3 *)",
|
||||||
"Bash(node --input-type=module)",
|
"Bash(node --input-type=module)",
|
||||||
"Bash(2>&1)",
|
"Bash(2>&1)",
|
||||||
"Bash(pnpm *)"
|
"Bash(pnpm *)"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
80
apps/api/prisma/schema.prisma
Normal file
80
apps/api/prisma/schema.prisma
Normal 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")
|
||||||
|
}
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { MissionsModule } from './missions/missions.module';
|
import { MissionsModule } from './missions/missions.module';
|
||||||
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { TickEngineModule } from './tick-engine/tick-engine.module';
|
import { TickEngineModule } from './tick-engine/tick-engine.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
|
ThrottlerModule.forRoot({
|
||||||
|
throttlers: [{ ttl: 10000, limit: 30 }],
|
||||||
|
}),
|
||||||
|
PrismaModule,
|
||||||
MissionsModule,
|
MissionsModule,
|
||||||
TickEngineModule,
|
TickEngineModule,
|
||||||
],
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
NotFoundException,
|
|
||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import {
|
import {
|
||||||
MissionStateResponse,
|
MissionStateResponse,
|
||||||
MissionStateResponseSchema,
|
MissionStateResponseSchema,
|
||||||
@@ -26,6 +27,7 @@ export class MissionsController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('state')
|
@Get('state')
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 10000 } })
|
||||||
async getState(
|
async getState(
|
||||||
@TwitchClaims() claims: TwitchJwtPayload
|
@TwitchClaims() claims: TwitchJwtPayload
|
||||||
): Promise<MissionStateResponse> {
|
): Promise<MissionStateResponse> {
|
||||||
@@ -38,9 +40,9 @@ export class MissionsController {
|
|||||||
async startMission(
|
async startMission(
|
||||||
@TwitchClaims() claims: TwitchJwtPayload,
|
@TwitchClaims() claims: TwitchJwtPayload,
|
||||||
@Body() body: unknown
|
@Body() body: unknown
|
||||||
): Promise<MissionStateResponse> {
|
): Promise<NonNullable<MissionStateResponse>> {
|
||||||
if (!claims.opaque_user_id.startsWith('U')) {
|
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);
|
const { difficulty } = StartMissionRequestSchema.parse(body);
|
||||||
return this.missions.startMission(claims, difficulty);
|
return this.missions.startMission(claims, difficulty);
|
||||||
|
|||||||
@@ -15,6 +15,6 @@ import { MissionsService } from './missions.service';
|
|||||||
EncounterService,
|
EncounterService,
|
||||||
GroupSynergyService,
|
GroupSynergyService,
|
||||||
],
|
],
|
||||||
exports: [MissionStoreService, EncounterService],
|
exports: [MissionStoreService, EncounterService, GroupSynergyService],
|
||||||
})
|
})
|
||||||
export class MissionsModule {}
|
export class MissionsModule {}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
} from '@fog-explorer/api-interfaces';
|
} from '@fog-explorer/api-interfaces';
|
||||||
import { getLibraryVersion } from '@fog-explorer/encounter-library';
|
import { getLibraryVersion } from '@fog-explorer/encounter-library';
|
||||||
import { TwitchJwtPayload } from '../auth/twitch-jwt.guard';
|
import { TwitchJwtPayload } from '../auth/twitch-jwt.guard';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { MissionStoreService } from './mission-store.service';
|
import { MissionStoreService } from './mission-store.service';
|
||||||
|
|
||||||
const TICK_BASE_INTERVAL_MS = 60_000;
|
const TICK_BASE_INTERVAL_MS = 60_000;
|
||||||
@@ -14,7 +15,10 @@ const TICK_JITTER_MS = 5_000;
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MissionsService {
|
export class MissionsService {
|
||||||
constructor(private readonly store: MissionStoreService) {}
|
constructor(
|
||||||
|
private readonly store: MissionStoreService,
|
||||||
|
private readonly prisma: PrismaService
|
||||||
|
) {}
|
||||||
|
|
||||||
async startMission(
|
async startMission(
|
||||||
claims: TwitchJwtPayload,
|
claims: TwitchJwtPayload,
|
||||||
@@ -22,11 +26,50 @@ export class MissionsService {
|
|||||||
): Promise<NonNullable<MissionStateResponse>> {
|
): Promise<NonNullable<MissionStateResponse>> {
|
||||||
const missionId = crypto.randomUUID();
|
const missionId = crypto.randomUUID();
|
||||||
const survivorId = 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 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 = {
|
const survivor: Survivor = {
|
||||||
id: survivorId,
|
id: survivorId,
|
||||||
@@ -36,7 +79,7 @@ export class MissionsService {
|
|||||||
state: 'active',
|
state: 'active',
|
||||||
stats,
|
stats,
|
||||||
perkSlots: [],
|
perkSlots: [],
|
||||||
createdAt: now,
|
createdAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mission: Mission = {
|
const mission: Mission = {
|
||||||
@@ -46,9 +89,9 @@ export class MissionsService {
|
|||||||
difficulty,
|
difficulty,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
encounterLibraryVersion: getLibraryVersion(),
|
encounterLibraryVersion: getLibraryVersion(),
|
||||||
nextTickAt,
|
nextTickAt: nextTickAt.toISOString(),
|
||||||
tickIndex: 0,
|
tickIndex: 0,
|
||||||
startedAt: now,
|
startedAt: now.toISOString(),
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,16 +103,12 @@ export class MissionsService {
|
|||||||
|
|
||||||
await this.store.setActiveMission(state);
|
await this.store.setActiveMission(state);
|
||||||
await this.store.setChannelMissionId(claims.channel_id, missionId);
|
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;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultStats(): SurvivorStats {
|
|
||||||
return { objectives: 5, survival: 5, altruism: 5 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultName(opaqueUserId: string): string {
|
function defaultName(opaqueUserId: string): string {
|
||||||
return `Survivor ${opaqueUserId.slice(-4)}`;
|
return `Survivor ${opaqueUserId.slice(-4)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
9
apps/api/src/app/prisma/prisma.module.ts
Normal file
9
apps/api/src/app/prisma/prisma.module.ts
Normal 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 {}
|
||||||
13
apps/api/src/app/prisma/prisma.service.ts
Normal file
13
apps/api/src/app/prisma/prisma.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MissionsModule } from '../missions/missions.module';
|
import { MissionsModule } from '../missions/missions.module';
|
||||||
import { TickService } from './tick.service';
|
import { TickService } from './tick.service';
|
||||||
|
import { TwitchPubSubService } from './twitch-pubsub.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MissionsModule],
|
imports: [MissionsModule],
|
||||||
providers: [TickService],
|
providers: [TickService, TwitchPubSubService],
|
||||||
})
|
})
|
||||||
export class TickEngineModule {}
|
export class TickEngineModule {}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
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 { EncounterService } from '../missions/encounter.service';
|
||||||
|
import { MissionStoreService } from '../missions/mission-store.service';
|
||||||
|
import { TwitchPubSubService } from './twitch-pubsub.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TickService {
|
export class TickService {
|
||||||
@@ -9,7 +11,9 @@ export class TickService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly store: MissionStoreService,
|
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)
|
@Cron(CronExpression.EVERY_10_SECONDS)
|
||||||
@@ -24,7 +28,7 @@ export class TickService {
|
|||||||
|
|
||||||
private async processMission(missionId: string): Promise<void> {
|
private async processMission(missionId: string): Promise<void> {
|
||||||
const token = await this.store.acquireLock(missionId);
|
const token = await this.store.acquireLock(missionId);
|
||||||
if (!token) return; // Another worker has the lock
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const state = await this.store.getActiveMission(missionId);
|
const state = await this.store.getActiveMission(missionId);
|
||||||
@@ -34,13 +38,57 @@ export class TickService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updated = this.encounters.processTick(state);
|
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);
|
await this.store.setActiveMission(updated);
|
||||||
|
|
||||||
if (
|
if (updated.mission.status === 'active') {
|
||||||
updated.mission.status === 'active' ||
|
|
||||||
updated.mission.status === 'lobby'
|
|
||||||
) {
|
|
||||||
const nextMs = new Date(updated.mission.nextTickAt).getTime();
|
const nextMs = new Date(updated.mission.nextTickAt).getTime();
|
||||||
await this.store.scheduleTick(missionId, nextMs);
|
await this.store.scheduleTick(missionId, nextMs);
|
||||||
} else {
|
} else {
|
||||||
@@ -52,10 +100,22 @@ export class TickService {
|
|||||||
tickIndex: updated.mission.tickIndex,
|
tickIndex: updated.mission.tickIndex,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.pubsub.broadcast(state.mission.participants[0]
|
||||||
|
? await this.getChannelId(missionId)
|
||||||
|
: null, updated);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error({ message: 'tick failed', missionId, err });
|
this.logger.error({ message: 'tick failed', missionId, err });
|
||||||
} finally {
|
} finally {
|
||||||
await this.store.releaseLock(missionId, token);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
apps/api/src/app/tick-engine/twitch-pubsub.service.ts
Normal file
72
apps/api/src/app/tick-engine/twitch-pubsub.service.ts
Normal 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}`;
|
||||||
|
}
|
||||||
@@ -9,12 +9,26 @@
|
|||||||
"flavorSuccess": [
|
"flavorSuccess": [
|
||||||
"The generator sputters to life. Light floods the area.",
|
"The generator sputters to life. Light floods the area.",
|
||||||
"Sparks fly, then hum — the generator catches.",
|
"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": [
|
"flavorFailure": [
|
||||||
"The generator kicks back. Too many watchers in the dark.",
|
"The generator kicks back. Too many watchers in the dark.",
|
||||||
"The mechanism jams. Footsteps echo nearby.",
|
"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": [
|
"flavorSuccess": [
|
||||||
"The totem crumbles. Its curse lifts from the fog.",
|
"The totem crumbles. Its curse lifts from the fog.",
|
||||||
"Bones scatter. The hex dissolves into smoke.",
|
"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": [
|
"flavorFailure": [
|
||||||
"The totem resists. Its pull is stronger than expected.",
|
"The totem resists. Its pull is stronger than expected.",
|
||||||
"A presence drives the survivor back before the cleanse completes.",
|
"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": [
|
"flavorSuccess": [
|
||||||
"The chest yields a worn medkit. Small mercies.",
|
"The chest yields a worn medkit. Small mercies.",
|
||||||
"A flashlight, still charged. The fog recedes slightly.",
|
"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": [
|
"flavorFailure": [
|
||||||
"The chest is empty. Only rust and regret.",
|
"The chest is empty. Only rust and regret.",
|
||||||
"The lid splinters — nothing useful inside.",
|
"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": [
|
"flavorSuccess": [
|
||||||
"With grim determination, the survivor slips free.",
|
"With grim determination, the survivor slips free.",
|
||||||
"Arms aching, the hook releases. Freedom, for now.",
|
"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": [
|
"flavorFailure": [
|
||||||
"The struggle exhausts. The hook holds.",
|
"The struggle exhausts. The hook holds.",
|
||||||
"Every movement drives the barb deeper. Stay still.",
|
"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": [
|
"flavorSuccess": [
|
||||||
"The gate grinds open. Cold air rushes in.",
|
"The gate grinds open. Cold air rushes in.",
|
||||||
"Generators humming, the lock gives. Almost there.",
|
"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": [
|
"flavorFailure": [
|
||||||
"The gate mechanism is jammed. Precious seconds lost.",
|
"The gate mechanism is jammed. Precious seconds lost.",
|
||||||
"A shadow falls across the panel. The attempt abandoned.",
|
"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": [
|
"flavorSuccess": [
|
||||||
"Still as stone. The threat passes without noticing.",
|
"Still as stone. The threat passes without noticing.",
|
||||||
"A breath held long — then silence. Safe.",
|
"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": [
|
"flavorFailure": [
|
||||||
"A twig snaps. Eye contact — then the chase begins.",
|
"A twig snaps. Eye contact — then the chase begins.",
|
||||||
"The survivor misjudges the angle. Spotted.",
|
"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": [
|
"flavorSuccess": [
|
||||||
"Bandages tight, the wound closes. Pain recedes.",
|
"Bandages tight, the wound closes. Pain recedes.",
|
||||||
"The medkit does its job. The survivor steadies.",
|
"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": [
|
"flavorFailure": [
|
||||||
"Supplies exhausted before the job is done.",
|
"Supplies exhausted before the job is done.",
|
||||||
"Shaking hands fumble the medkit. Time runs out.",
|
"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": [
|
"flavorSuccess": [
|
||||||
"The pallet crashes down. A moment bought.",
|
"The pallet crashes down. A moment bought.",
|
||||||
"Timber splinters between them. Distance gained.",
|
"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": [
|
"flavorFailure": [
|
||||||
"The pallet drops wide. No gap created.",
|
"The pallet drops wide. No gap created.",
|
||||||
"Too slow — the obstacle proves useless.",
|
"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": [
|
"flavorSuccess": [
|
||||||
"The basement yields rare supplies. Worth the risk.",
|
"The basement yields rare supplies. Worth the risk.",
|
||||||
"A pristine toolbox — almost worth dying for.",
|
"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": [
|
"flavorFailure": [
|
||||||
"The basement was a trap. Retreat costs dearly.",
|
"The basement was a trap. Retreat costs dearly.",
|
||||||
"The stairwell offers no escape. A mistake made clear.",
|
"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": [
|
"flavorSuccess": [
|
||||||
"The hatch sighs open. One last mercy from the fog.",
|
"The hatch sighs open. One last mercy from the fog.",
|
||||||
"A sound — familiar, haunting. The hatch, just ahead.",
|
"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": [
|
"flavorFailure": [
|
||||||
"The hatch is nowhere. Only fog and silence.",
|
"The hatch is nowhere. Only fog and silence.",
|
||||||
"Close — so close. Then it closes.",
|
"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."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './lib/encounter-resolver';
|
export * from './lib/encounter-resolver';
|
||||||
|
export * from './lib/perk-math';
|
||||||
|
|||||||
@@ -198,6 +198,69 @@ describe('resolveEncounter', () => {
|
|||||||
expect(result.logText.length).toBeGreaterThan(0);
|
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 ──────────────────────────────────────────────────────
|
// ── Property-based tests ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import seedrandom = require('seedrandom');
|
|||||||
import type {
|
import type {
|
||||||
EncounterDefinition,
|
EncounterDefinition,
|
||||||
EncounterResult,
|
EncounterResult,
|
||||||
ModifierApplication,
|
|
||||||
Perk,
|
Perk,
|
||||||
SurvivorState,
|
SurvivorState,
|
||||||
SurvivorStats,
|
SurvivorStats,
|
||||||
} from '@fog-explorer/api-interfaces';
|
} from '@fog-explorer/api-interfaces';
|
||||||
|
import { applyPerkModifiers } from './perk-math';
|
||||||
|
|
||||||
export interface ResolverInput {
|
export interface ResolverInput {
|
||||||
seed: string;
|
seed: string;
|
||||||
@@ -23,15 +23,10 @@ export interface ResolverInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Probability adjustments per stat point and per difficulty level.
|
|
||||||
const OBJECTIVES_BONUS = 0.02;
|
const OBJECTIVES_BONUS = 0.02;
|
||||||
const ALTRUISM_BONUS = 0.02;
|
const ALTRUISM_BONUS = 0.02;
|
||||||
const DIFFICULTY_PENALTY = 0.12;
|
const DIFFICULTY_PENALTY = 0.12;
|
||||||
|
|
||||||
// Tag that enables the altruism stat bonus.
|
|
||||||
const ALTRUISM_TAG = 'altruistic';
|
const ALTRUISM_TAG = 'altruistic';
|
||||||
|
|
||||||
// Injury probability floor/ceiling when active survivor fails.
|
|
||||||
const INJURY_CHANCE_BASE = 0.8;
|
const INJURY_CHANCE_BASE = 0.8;
|
||||||
const INJURY_CHANCE_FLOOR = 0.2;
|
const INJURY_CHANCE_FLOOR = 0.2;
|
||||||
const SURVIVAL_INJURY_REDUCTION = 0.05;
|
const SURVIVAL_INJURY_REDUCTION = 0.05;
|
||||||
@@ -40,25 +35,28 @@ export function resolveEncounter(input: ResolverInput): EncounterResult {
|
|||||||
const rng = seedrandom(input.seed);
|
const rng = seedrandom(input.seed);
|
||||||
|
|
||||||
let probability = input.encounter.baseProbability;
|
let probability = input.encounter.baseProbability;
|
||||||
|
|
||||||
probability -= (input.difficulty - 1) * DIFFICULTY_PENALTY;
|
probability -= (input.difficulty - 1) * DIFFICULTY_PENALTY;
|
||||||
probability += input.survivor.stats.objectives * OBJECTIVES_BONUS;
|
probability += input.survivor.stats.objectives * OBJECTIVES_BONUS;
|
||||||
|
|
||||||
if (input.encounter.tags.includes(ALTRUISM_TAG)) {
|
if (input.encounter.tags.includes(ALTRUISM_TAG)) {
|
||||||
probability += input.survivor.stats.altruism * ALTRUISM_BONUS;
|
probability += input.survivor.stats.altruism * ALTRUISM_BONUS;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modifiersApplied = applyPerkModifiers(probability, input);
|
const { probability: finalProbability, applied } = applyPerkModifiers(
|
||||||
probability = modifiersApplied.adjusted;
|
probability,
|
||||||
const appliedList = modifiersApplied.applied;
|
input.survivor.perkSlots,
|
||||||
|
input.encounter.tags
|
||||||
|
);
|
||||||
|
|
||||||
probability = Math.max(0, Math.min(1, probability));
|
const success = rng() < finalProbability;
|
||||||
|
|
||||||
const success = rng() < probability;
|
|
||||||
|
|
||||||
const survivorStateChange = success
|
const survivorStateChange = success
|
||||||
? null
|
? 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 {
|
return {
|
||||||
missionId: input.missionId,
|
missionId: input.missionId,
|
||||||
@@ -68,47 +66,11 @@ export function resolveEncounter(input: ResolverInput): EncounterResult {
|
|||||||
seed: input.seed,
|
seed: input.seed,
|
||||||
success,
|
success,
|
||||||
survivorStateChange,
|
survivorStateChange,
|
||||||
modifiersApplied: appliedList,
|
modifiersApplied: applied,
|
||||||
logText: buildLogText(input.encounter.key, success, survivorStateChange),
|
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(
|
function computeStateChange(
|
||||||
rng: seedrandom.PRNG,
|
rng: seedrandom.PRNG,
|
||||||
currentState: SurvivorState,
|
currentState: SurvivorState,
|
||||||
|
|||||||
305
libs/mission-logic/src/lib/monte-carlo.spec.ts
Normal file
305
libs/mission-logic/src/lib/monte-carlo.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
225
libs/mission-logic/src/lib/perk-math.spec.ts
Normal file
225
libs/mission-logic/src/lib/perk-math.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
101
libs/mission-logic/src/lib/perk-math.ts
Normal file
101
libs/mission-logic/src/lib/perk-math.ts
Normal 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));
|
||||||
|
}
|
||||||
19
package.json
19
package.json
@@ -52,6 +52,7 @@
|
|||||||
"jsonc-eslint-parser": "^2.1.0",
|
"jsonc-eslint-parser": "^2.1.0",
|
||||||
"nx": "22.7.1",
|
"nx": "22.7.1",
|
||||||
"prettier": "~3.6.2",
|
"prettier": "~3.6.2",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
"ts-jest": "^29.4.0",
|
"ts-jest": "^29.4.0",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
@@ -72,11 +73,29 @@
|
|||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"seedrandom": "^3.0.5",
|
"seedrandom": "^3.0.5",
|
||||||
"zod": "^4.4.3"
|
"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
82
pnpm-lock.yaml
generated
@@ -38,6 +38,12 @@ importers:
|
|||||||
'@nestjs/schedule':
|
'@nestjs/schedule':
|
||||||
specifier: ^6.1.3
|
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)
|
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:
|
axios:
|
||||||
specifier: ^1.6.0
|
specifier: ^1.6.0
|
||||||
version: 1.15.0
|
version: 1.15.0
|
||||||
@@ -198,6 +204,9 @@ importers:
|
|||||||
prettier:
|
prettier:
|
||||||
specifier: ~3.6.2
|
specifier: ~3.6.2
|
||||||
version: 3.6.2
|
version: 3.6.2
|
||||||
|
prisma:
|
||||||
|
specifier: ^5.22.0
|
||||||
|
version: 5.22.0
|
||||||
ts-jest:
|
ts-jest:
|
||||||
specifier: ^29.4.0
|
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)
|
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':
|
'@nestjs/platform-express':
|
||||||
optional: true
|
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':
|
'@noble/hashes@1.4.0':
|
||||||
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
|
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
|
||||||
engines: {node: '>= 16'}
|
engines: {node: '>= 16'}
|
||||||
@@ -2661,6 +2677,30 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
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':
|
'@rolldown/binding-android-arm64@1.0.0-rc.17':
|
||||||
resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==}
|
resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -6637,6 +6677,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==}
|
resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==}
|
||||||
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
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:
|
proc-log@6.1.0:
|
||||||
resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==}
|
resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==}
|
||||||
engines: {node: ^20.17.0 || >=22.9.0}
|
engines: {node: ^20.17.0 || >=22.9.0}
|
||||||
@@ -10398,6 +10443,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
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/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': {}
|
'@noble/hashes@1.4.0': {}
|
||||||
|
|
||||||
'@npmcli/agent@4.0.0':
|
'@npmcli/agent@4.0.0':
|
||||||
@@ -11250,6 +11301,31 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
playwright: 1.59.1
|
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':
|
'@rolldown/binding-android-arm64@1.0.0-rc.17':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -15648,6 +15724,12 @@ snapshots:
|
|||||||
ansi-styles: 5.2.0
|
ansi-styles: 5.2.0
|
||||||
react-is: 18.3.1
|
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: {}
|
proc-log@6.1.0: {}
|
||||||
|
|
||||||
process-nextick-args@2.0.1: {}
|
process-nextick-args@2.0.1: {}
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@nestjs/core': true
|
'@nestjs/core': true
|
||||||
'@parcel/watcher': true
|
'@parcel/watcher': true
|
||||||
|
'@prisma/engines': true
|
||||||
'@swc/core': true
|
'@swc/core': true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
less: true
|
less: true
|
||||||
lmdb: true
|
lmdb: true
|
||||||
msgpackr-extract: true
|
msgpackr-extract: true
|
||||||
nx: true
|
nx: true
|
||||||
|
prisma: true
|
||||||
unrs-resolver: true
|
unrs-resolver: true
|
||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- '@nestjs/core'
|
- '@nestjs/core'
|
||||||
- '@parcel/watcher'
|
- '@parcel/watcher'
|
||||||
|
- '@prisma/engines'
|
||||||
- '@swc/core'
|
- '@swc/core'
|
||||||
- esbuild
|
- esbuild
|
||||||
- less
|
- less
|
||||||
- lmdb
|
- lmdb
|
||||||
- msgpackr-extract
|
- msgpackr-extract
|
||||||
- nx
|
- nx
|
||||||
|
- prisma
|
||||||
- unrs-resolver
|
- unrs-resolver
|
||||||
|
|||||||
Reference in New Issue
Block a user