Compare commits
3 Commits
e8523d270e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0517202412 | ||
|
|
0031ef0a8f | ||
|
|
21f1a5319f |
@@ -2,6 +2,9 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(node -e *)",
|
||||
"Bash(python3 *)",
|
||||
"Bash(curl *)",
|
||||
"Bash(node *)",
|
||||
"Bash(node --input-type=module)",
|
||||
"Bash(2>&1)",
|
||||
"Bash(pnpm *)"
|
||||
|
||||
5
.env
Normal file
@@ -0,0 +1,5 @@
|
||||
DATABASE_URL="postgresql://fog:fog_dev@172.20.0.3:5432/fog_expedition"
|
||||
REDIS_HOST="172.20.0.2"
|
||||
REDIS_PORT="6379"
|
||||
TWITCH_EXTENSION_SECRET="dev_secret_placeholder"
|
||||
DEV_AUTH_BYPASS="true"
|
||||
@@ -31,18 +31,21 @@ This is a Twitch Video Overlay extension implementing an autonomous tick-based Z
|
||||
- Sentence case in UI text and log messages.
|
||||
- Zod schemas (not just TS types) at the EBS boundary.
|
||||
- Pure functions for game logic. Seeded PRNG via `seedrandom`, never `Math.random()`. Persist seeds in `mission_logs`.
|
||||
- Migrations from day one. No "I'll add migrations later."
|
||||
- Migrations from day one. No "I'll add migrations later." After writing a migration file, always run `pnpm exec prisma migrate deploy --schema=apps/api/prisma/schema.prisma` immediately — the regenerated Prisma client will SELECT new columns on every query and cause 500s until the column exists in the database.
|
||||
- Round every displayed number; JS float math leaks artifacts.
|
||||
- Structured logging with correlation IDs (`missionId`, `tickIndex`, `channelId`, `opaqueUserId`).
|
||||
- Bind Nest services to `0.0.0.0`, not `127.0.0.1`. The Twitch dev rig on Windows reaches them via the WSL2/devcontainer port forwarding chain.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- Don’t assume. Don’t hide confusion. Surface tradeoffs.
|
||||
- Minimum code that solves the problem. Nothing speculative.
|
||||
- Touch only what you must. Clean up only your own mess.
|
||||
- Define success criteria. Loop until verified.
|
||||
- Do not use `Math.random()` anywhere in game logic.
|
||||
- Do not skip `nextTickAt` jitter — synchronised global ticks will thunder-herd Postgres.
|
||||
- Do not assume Twitch PubSub message delivery; treat as a hint to refresh, reconcile from REST.
|
||||
- Do not add inline scripts or inline event handlers to the overlay HTML — Twitch CSP forbids them.
|
||||
- Do not reproduce Dead by Daylight assets, character names, or trademarked terms in encounter content. The aesthetic is *inspired by* the genre, not a clone.
|
||||
|
||||
## Twitch extension specifics
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "missions" ADD COLUMN "duration_minutes" SMALLINT NOT NULL DEFAULT 20;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "missions" ADD COLUMN "killer_name" TEXT;
|
||||
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
82
apps/api/prisma/schema.prisma
Normal file
@@ -0,0 +1,82 @@
|
||||
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
|
||||
durationMinutes Int @default(20) @map("duration_minutes") @db.SmallInt
|
||||
status String @default("active")
|
||||
killerName String? @map("killer_name")
|
||||
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 { APP_GUARD } from '@nestjs/core';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { AppLoggerModule } from './logger/logger.module';
|
||||
import { MissionsModule } from './missions/missions.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { TickEngineModule } from './tick-engine/tick-engine.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AppLoggerModule,
|
||||
ScheduleModule.forRoot(),
|
||||
ThrottlerModule.forRoot({
|
||||
throttlers: [{ ttl: 1000, limit: 300 }],
|
||||
}),
|
||||
PrismaModule,
|
||||
MissionsModule,
|
||||
TickEngineModule,
|
||||
],
|
||||
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { createHmac } from 'crypto';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
export interface TwitchJwtPayload {
|
||||
opaque_user_id: string;
|
||||
@@ -17,6 +18,7 @@ export interface TwitchJwtPayload {
|
||||
interface HttpRequest {
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
twitchClaims?: TwitchJwtPayload;
|
||||
log?: { bindings: () => Record<string, unknown>; child: (b: Record<string, unknown>) => unknown };
|
||||
}
|
||||
|
||||
export const TwitchClaims = createParamDecorator(
|
||||
@@ -29,6 +31,10 @@ export const TwitchClaims = createParamDecorator(
|
||||
|
||||
@Injectable()
|
||||
export class TwitchJwtGuard implements CanActivate {
|
||||
constructor(private readonly logger: PinoLogger) {
|
||||
this.logger.setContext(TwitchJwtGuard.name);
|
||||
}
|
||||
|
||||
canActivate(ctx: ExecutionContext): boolean {
|
||||
const req = ctx.switchToHttp().getRequest<HttpRequest>();
|
||||
const authHeader = req.headers['authorization'];
|
||||
@@ -36,9 +42,50 @@ export class TwitchJwtGuard implements CanActivate {
|
||||
if (!auth?.startsWith('Bearer ')) throw new UnauthorizedException();
|
||||
|
||||
const token = auth.slice(7);
|
||||
req.twitchClaims = verifyAndDecode(token);
|
||||
const devClaims = tryDevBypass(token);
|
||||
if (devClaims) {
|
||||
req.twitchClaims = devClaims;
|
||||
this.logger.warn({ msg: 'dev auth bypass active', opaqueUserId: devClaims.opaque_user_id });
|
||||
this.logger.assign({ channelId: devClaims.channel_id, opaqueUserId: devClaims.opaque_user_id });
|
||||
return true;
|
||||
}
|
||||
|
||||
req.twitchClaims = verifyAndDecode(token);
|
||||
|
||||
// Bind Twitch identity into this request's pino logger so every subsequent
|
||||
// log line in the request lifecycle carries channelId and opaqueUserId.
|
||||
this.logger.assign({
|
||||
channelId: req.twitchClaims.channel_id,
|
||||
opaqueUserId: req.twitchClaims.opaque_user_id,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts the fake dev JWT minted by TwitchAuthService.buildDevAuth() when
|
||||
* NODE_ENV=development. Tokens are identified by their `.dev` signature and
|
||||
* a UDEV-prefixed opaque_user_id. Fails loudly if called outside dev mode —
|
||||
* this guard must NEVER run in production.
|
||||
*/
|
||||
function tryDevBypass(token: string): TwitchJwtPayload | null {
|
||||
if (process.env['NODE_ENV'] !== 'development') return null;
|
||||
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3 || parts[2] !== 'dev') return null;
|
||||
|
||||
try {
|
||||
const raw = Buffer.from(
|
||||
parts[1].replace(/-/g, '+').replace(/_/g, '/'),
|
||||
'base64'
|
||||
).toString('utf8');
|
||||
const payload = JSON.parse(raw) as TwitchJwtPayload;
|
||||
if (!payload.opaque_user_id?.startsWith('UDEV')) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function verifyAndDecode(token: string): TwitchJwtPayload {
|
||||
|
||||
27
apps/api/src/app/logger/logger.module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
|
||||
const isDev = process.env['NODE_ENV'] !== 'production';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
level: process.env['LOG_LEVEL'] ?? (isDev ? 'debug' : 'info'),
|
||||
// pino-pretty in dev; raw JSON in prod (consumed by log aggregators).
|
||||
transport: isDev
|
||||
? { target: 'pino-pretty', options: { colorize: true, singleLine: false } }
|
||||
: undefined,
|
||||
// Suppress /health probes from cluttering the log stream.
|
||||
autoLogging: {
|
||||
ignore: (req) => req.url?.includes('/health') ?? false,
|
||||
},
|
||||
serializers: {
|
||||
req: (req) => ({ method: req.method, url: req.url }),
|
||||
res: (res) => ({ statusCode: res.statusCode }),
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class AppLoggerModule {}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import type {
|
||||
EncounterResult,
|
||||
Mission,
|
||||
@@ -21,16 +22,18 @@ const RECENT_LOG_MAX = 20;
|
||||
|
||||
@Injectable()
|
||||
export class EncounterService {
|
||||
private readonly logger = new Logger(EncounterService.name);
|
||||
|
||||
constructor(private readonly groupSynergy: GroupSynergyService) {}
|
||||
constructor(
|
||||
private readonly logger: PinoLogger,
|
||||
private readonly groupSynergy: GroupSynergyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolves one tick for the given mission state.
|
||||
* Returns the updated state, or null if the mission ended.
|
||||
* channelId is threaded in from the TickService for structured log correlation.
|
||||
*/
|
||||
processTick(
|
||||
current: NonNullable<MissionStateResponse>
|
||||
current: NonNullable<MissionStateResponse>,
|
||||
channelId: string | null,
|
||||
): NonNullable<MissionStateResponse> {
|
||||
const { mission, survivors } = current;
|
||||
const tickIndex = mission.tickIndex + 1;
|
||||
@@ -56,7 +59,7 @@ export class EncounterService {
|
||||
|
||||
const augmentedPerks = this.groupSynergy.buildAugmentedPerks(
|
||||
survivor,
|
||||
groupModifiers
|
||||
groupModifiers,
|
||||
);
|
||||
|
||||
const result = resolveEncounter({
|
||||
@@ -76,26 +79,27 @@ export class EncounterService {
|
||||
|
||||
const libEncounter = getEncounterById(encounter.key);
|
||||
const flavor = libEncounter
|
||||
? pickFlavor(libEncounter, { success: result.success }, rng)
|
||||
? pickFlavor(libEncounter, { success: result.success, killerName: mission.killerName }, rng)
|
||||
: result.logText;
|
||||
|
||||
newLog.push({ ...result, logText: flavor });
|
||||
|
||||
this.logger.log({
|
||||
message: 'tick resolved',
|
||||
this.logger.info({
|
||||
msg: 'tick resolved',
|
||||
missionId: mission.id,
|
||||
channelId: 'unknown',
|
||||
channelId,
|
||||
tickIndex,
|
||||
survivorId: survivor.id,
|
||||
encounterKey: encounter.key,
|
||||
success: result.success,
|
||||
seed: result.seed,
|
||||
modifiersApplied: result.modifiersApplied.length,
|
||||
});
|
||||
|
||||
const stateChange = result.survivorStateChange;
|
||||
if (stateChange) {
|
||||
updatedSurvivors = updatedSurvivors.map((s) =>
|
||||
s.id === survivor.id ? { ...s, state: stateChange.to } : s
|
||||
s.id === survivor.id ? { ...s, state: stateChange.to } : s,
|
||||
);
|
||||
updatedParticipants = updatedParticipants.map((p) => {
|
||||
if (p.survivorId !== survivor.id) return p;
|
||||
@@ -111,13 +115,13 @@ export class EncounterService {
|
||||
const updatedMission = buildUpdatedMission(
|
||||
mission,
|
||||
updatedParticipants,
|
||||
tickIndex
|
||||
tickIndex,
|
||||
);
|
||||
|
||||
const recentLog = [
|
||||
...newLog,
|
||||
...current.recentLog,
|
||||
].slice(0, RECENT_LOG_MAX);
|
||||
const recentLog = [...newLog, ...current.recentLog].slice(
|
||||
0,
|
||||
RECENT_LOG_MAX,
|
||||
);
|
||||
|
||||
return {
|
||||
mission: updatedMission,
|
||||
@@ -134,14 +138,9 @@ function buildSeed(missionId: string, tickIndex: number): string {
|
||||
function buildUpdatedMission(
|
||||
mission: Mission,
|
||||
participants: MissionParticipant[],
|
||||
tickIndex: number
|
||||
tickIndex: number,
|
||||
): Mission {
|
||||
const allSacrificed = participants.every(
|
||||
(p) => p.state === 'sacrificed'
|
||||
);
|
||||
const allEscaped = participants.every(
|
||||
(p) => p.state === 'active' || p.state === 'idle'
|
||||
);
|
||||
const allSacrificed = participants.every((p) => p.state === 'sacrificed');
|
||||
|
||||
let status = mission.status;
|
||||
let endedAt = mission.endedAt;
|
||||
@@ -149,15 +148,14 @@ function buildUpdatedMission(
|
||||
if (allSacrificed && mission.status === 'active') {
|
||||
status = 'sacrifice';
|
||||
endedAt = new Date().toISOString();
|
||||
} else if (allEscaped && tickIndex >= 10 && mission.status === 'active') {
|
||||
// Success after at least 10 ticks with all survivors still active
|
||||
} else if (!allSacrificed && tickIndex >= mission.durationMinutes && mission.status === 'active') {
|
||||
status = 'success';
|
||||
endedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
const jitter = Math.floor(Math.random() * TICK_JITTER_MS);
|
||||
const nextTickAt = new Date(
|
||||
Date.now() + TICK_BASE_INTERVAL_MS + jitter
|
||||
Date.now() + TICK_BASE_INTERVAL_MS + jitter,
|
||||
).toISOString();
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import {
|
||||
MissionStateResponse,
|
||||
MissionStateResponseSchema,
|
||||
StartMissionRequestSchema,
|
||||
} from '@fog-explorer/api-interfaces';
|
||||
import { TwitchClaims, TwitchJwtGuard, TwitchJwtPayload } from '../auth/twitch-jwt.guard';
|
||||
import {
|
||||
TwitchClaims,
|
||||
TwitchJwtGuard,
|
||||
TwitchJwtPayload,
|
||||
} from '../auth/twitch-jwt.guard';
|
||||
import { MissionStoreService } from './mission-store.service';
|
||||
import { MissionsService } from './missions.service';
|
||||
|
||||
@@ -22,27 +27,39 @@ import { MissionsService } from './missions.service';
|
||||
export class MissionsController {
|
||||
constructor(
|
||||
private readonly store: MissionStoreService,
|
||||
private readonly missions: MissionsService
|
||||
private readonly missions: MissionsService,
|
||||
) {}
|
||||
|
||||
@Get('state')
|
||||
@Throttle({ default: { limit: 100, ttl: 1000 } })
|
||||
async getState(
|
||||
@TwitchClaims() claims: TwitchJwtPayload
|
||||
@TwitchClaims() claims: TwitchJwtPayload,
|
||||
): Promise<MissionStateResponse> {
|
||||
const state = await this.store.getStateForChannel(claims.channel_id);
|
||||
return MissionStateResponseSchema.parse(state);
|
||||
}
|
||||
|
||||
@Post('abandon')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async abandonMission(
|
||||
@TwitchClaims() claims: TwitchJwtPayload,
|
||||
): Promise<void> {
|
||||
if (!claims.opaque_user_id.startsWith('U')) {
|
||||
throw new ForbiddenException('Anonymous viewers cannot abandon missions');
|
||||
}
|
||||
await this.missions.abandonMission(claims);
|
||||
}
|
||||
|
||||
@Post('start')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async startMission(
|
||||
@TwitchClaims() claims: TwitchJwtPayload,
|
||||
@Body() body: unknown
|
||||
): Promise<MissionStateResponse> {
|
||||
@Body() body: unknown,
|
||||
): Promise<NonNullable<MissionStateResponse>> {
|
||||
if (!claims.opaque_user_id.startsWith('U')) {
|
||||
throw new NotFoundException('Anonymous viewers cannot start missions');
|
||||
throw new ForbiddenException('Anonymous viewers cannot start missions');
|
||||
}
|
||||
const { difficulty } = StartMissionRequestSchema.parse(body);
|
||||
return this.missions.startMission(claims, difficulty);
|
||||
const { difficulty, durationMinutes, characterName } = StartMissionRequestSchema.parse(body);
|
||||
return this.missions.startMission(claims, difficulty, durationMinutes, characterName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { TwitchJwtGuard } from '../auth/twitch-jwt.guard';
|
||||
import { RedisModule } from '../redis/redis.module';
|
||||
import { EncounterService } from './encounter.service';
|
||||
import { GroupSynergyService } from './group-synergy.service';
|
||||
@@ -7,14 +9,15 @@ import { MissionsController } from './missions.controller';
|
||||
import { MissionsService } from './missions.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisModule],
|
||||
imports: [RedisModule, LoggerModule],
|
||||
controllers: [MissionsController],
|
||||
providers: [
|
||||
TwitchJwtGuard,
|
||||
MissionStoreService,
|
||||
MissionsService,
|
||||
EncounterService,
|
||||
GroupSynergyService,
|
||||
],
|
||||
exports: [MissionStoreService, EncounterService],
|
||||
exports: [MissionStoreService, EncounterService, GroupSynergyService],
|
||||
})
|
||||
export class MissionsModule {}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
Mission,
|
||||
MissionDurationMinutes,
|
||||
MissionStateResponse,
|
||||
Survivor,
|
||||
SurvivorStats,
|
||||
} from '@fog-explorer/api-interfaces';
|
||||
import { getLibraryVersion } from '@fog-explorer/encounter-library';
|
||||
import { getLibraryVersion, KILLER_NAMES } from '@fog-explorer/encounter-library';
|
||||
import seedrandom = require('seedrandom');
|
||||
import { TwitchJwtPayload } from '../auth/twitch-jwt.guard';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { MissionStoreService } from './mission-store.service';
|
||||
|
||||
const TICK_BASE_INTERVAL_MS = 60_000;
|
||||
@@ -14,29 +21,77 @@ const TICK_JITTER_MS = 5_000;
|
||||
|
||||
@Injectable()
|
||||
export class MissionsService {
|
||||
constructor(private readonly store: MissionStoreService) {}
|
||||
constructor(
|
||||
private readonly store: MissionStoreService,
|
||||
private readonly prisma: PrismaService
|
||||
) {}
|
||||
|
||||
async startMission(
|
||||
claims: TwitchJwtPayload,
|
||||
difficulty: number
|
||||
difficulty: number,
|
||||
durationMinutes: MissionDurationMinutes,
|
||||
characterName?: string,
|
||||
): Promise<NonNullable<MissionStateResponse>> {
|
||||
const missionId = crypto.randomUUID();
|
||||
const survivorId = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const now = new Date();
|
||||
const jitter = Math.floor(Math.random() * TICK_JITTER_MS);
|
||||
const nextTickAt = new Date(Date.now() + TICK_BASE_INTERVAL_MS + jitter).toISOString();
|
||||
const nextTickAt = new Date(now.getTime() + TICK_BASE_INTERVAL_MS + jitter);
|
||||
const stats: SurvivorStats = { objectives: 5, survival: 5, altruism: 5 };
|
||||
const survivorName = characterName ?? defaultName(claims.opaque_user_id);
|
||||
const killerName = pickKiller(missionId);
|
||||
|
||||
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: survivorName,
|
||||
state: 'active',
|
||||
stats,
|
||||
perkSlots: [],
|
||||
},
|
||||
});
|
||||
|
||||
await tx.mission.create({
|
||||
data: {
|
||||
id: missionId,
|
||||
channelId: claims.channel_id,
|
||||
difficulty,
|
||||
durationMinutes,
|
||||
status: 'active',
|
||||
killerName,
|
||||
encounterLibraryVersion: getLibraryVersion(),
|
||||
nextTickAt,
|
||||
participants: {
|
||||
create: {
|
||||
id: crypto.randomUUID(),
|
||||
survivorId,
|
||||
state: 'active',
|
||||
hookCount: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const survivor: Survivor = {
|
||||
id: survivorId,
|
||||
opaqueUserId: claims.opaque_user_id,
|
||||
channelId: claims.channel_id,
|
||||
name: defaultName(claims.opaque_user_id),
|
||||
name: survivorName,
|
||||
state: 'active',
|
||||
stats,
|
||||
perkSlots: [],
|
||||
createdAt: now,
|
||||
createdAt: now.toISOString(),
|
||||
};
|
||||
|
||||
const mission: Mission = {
|
||||
@@ -44,11 +99,13 @@ export class MissionsService {
|
||||
groupId: null,
|
||||
participants: [{ survivorId, state: 'active', hookCount: 0 }],
|
||||
difficulty,
|
||||
durationMinutes,
|
||||
status: 'active',
|
||||
killerName,
|
||||
encounterLibraryVersion: getLibraryVersion(),
|
||||
nextTickAt,
|
||||
nextTickAt: nextTickAt.toISOString(),
|
||||
tickIndex: 0,
|
||||
startedAt: now,
|
||||
startedAt: now.toISOString(),
|
||||
endedAt: null,
|
||||
};
|
||||
|
||||
@@ -60,16 +117,49 @@ export class MissionsService {
|
||||
|
||||
await this.store.setActiveMission(state);
|
||||
await this.store.setChannelMissionId(claims.channel_id, missionId);
|
||||
await this.store.scheduleTick(missionId, new Date(nextTickAt).getTime());
|
||||
await this.store.scheduleTick(missionId, nextTickAt.getTime());
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async abandonMission(claims: TwitchJwtPayload): Promise<void> {
|
||||
const state = await this.store.getStateForChannel(claims.channel_id);
|
||||
|
||||
if (!state) {
|
||||
throw new NotFoundException('No active mission');
|
||||
}
|
||||
if (state.mission.status !== 'active') {
|
||||
throw new ForbiddenException('Mission is not active');
|
||||
}
|
||||
|
||||
function defaultStats(): SurvivorStats {
|
||||
return { objectives: 5, survival: 5, altruism: 5 };
|
||||
const isParticipant = state.survivors.some(
|
||||
(s) => s.opaqueUserId === claims.opaque_user_id,
|
||||
);
|
||||
if (!isParticipant) {
|
||||
throw new ForbiddenException('Not a participant in this mission');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updated: NonNullable<MissionStateResponse> = {
|
||||
...state,
|
||||
mission: { ...state.mission, status: 'abandoned', endedAt: now },
|
||||
};
|
||||
|
||||
await this.prisma.mission.update({
|
||||
where: { id: state.mission.id },
|
||||
data: { status: 'abandoned', endedAt: new Date(now) },
|
||||
});
|
||||
|
||||
await this.store.setActiveMission(updated);
|
||||
await this.store.removeMissionFromQueue(state.mission.id);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultName(opaqueUserId: string): string {
|
||||
return `Survivor ${opaqueUserId.slice(-4)}`;
|
||||
}
|
||||
|
||||
function pickKiller(missionId: string): string {
|
||||
const rng = seedrandom(`killer:${missionId}`);
|
||||
return KILLER_NAMES[Math.floor(rng() * KILLER_NAMES.length)];
|
||||
}
|
||||
|
||||
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
@@ -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,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { MissionsModule } from '../missions/missions.module';
|
||||
import { TickService } from './tick.service';
|
||||
import { TwitchPubSubService } from './twitch-pubsub.service';
|
||||
|
||||
@Module({
|
||||
imports: [MissionsModule],
|
||||
providers: [TickService],
|
||||
imports: [MissionsModule, LoggerModule],
|
||||
providers: [TickService, TwitchPubSubService],
|
||||
})
|
||||
export class TickEngineModule {}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { MissionStoreService } from '../missions/mission-store.service';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { EncounterService } from '../missions/encounter.service';
|
||||
import { MissionStoreService } from '../missions/mission-store.service';
|
||||
import { TwitchPubSubService } from './twitch-pubsub.service';
|
||||
|
||||
@Injectable()
|
||||
export class TickService {
|
||||
private readonly logger = new Logger(TickService.name);
|
||||
|
||||
constructor(
|
||||
private readonly logger: PinoLogger,
|
||||
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)
|
||||
@@ -18,13 +22,17 @@ export class TickService {
|
||||
const dueMissionIds = await this.store.getDueMissionIds(now);
|
||||
|
||||
await Promise.allSettled(
|
||||
dueMissionIds.map((id) => this.processMission(id))
|
||||
dueMissionIds.map((id) => this.processMission(id)),
|
||||
);
|
||||
}
|
||||
|
||||
private async processMission(missionId: string): Promise<void> {
|
||||
const token = await this.store.acquireLock(missionId);
|
||||
if (!token) return; // Another worker has the lock
|
||||
if (!token) return;
|
||||
|
||||
// Fetch channelId up-front so every log line in this mission carries it.
|
||||
const channelId = await this.getChannelId(missionId);
|
||||
const log = this.logger.logger.child({ missionId, channelId });
|
||||
|
||||
try {
|
||||
const state = await this.store.getActiveMission(missionId);
|
||||
@@ -33,29 +41,87 @@ export class TickService {
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = this.encounters.processTick(state);
|
||||
const updated = this.encounters.processTick(state, channelId);
|
||||
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((entry) => ({
|
||||
id: crypto.randomUUID(),
|
||||
missionId,
|
||||
tickIndex: entry.tickIndex,
|
||||
encounterKey: entry.encounterKey,
|
||||
renderedText: entry.logText,
|
||||
seed: entry.seed,
|
||||
modifiersApplied: entry.modifiersApplied,
|
||||
})),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update Redis cache.
|
||||
await this.store.setActiveMission(updated);
|
||||
|
||||
if (
|
||||
updated.mission.status === 'active' ||
|
||||
updated.mission.status === 'lobby'
|
||||
) {
|
||||
if (updated.mission.status === 'active') {
|
||||
const nextMs = new Date(updated.mission.nextTickAt).getTime();
|
||||
await this.store.scheduleTick(missionId, nextMs);
|
||||
} else {
|
||||
await this.store.removeMissionFromQueue(missionId);
|
||||
this.logger.log({
|
||||
message: 'mission ended',
|
||||
missionId,
|
||||
log.info({
|
||||
msg: 'mission ended',
|
||||
status: updated.mission.status,
|
||||
tickIndex: updated.mission.tickIndex,
|
||||
});
|
||||
}
|
||||
|
||||
await this.pubsub.broadcast(channelId, updated);
|
||||
} catch (err) {
|
||||
this.logger.error({ message: 'tick failed', missionId, err });
|
||||
log.error({ msg: 'tick failed', err });
|
||||
} finally {
|
||||
await this.store.releaseLock(missionId, token);
|
||||
}
|
||||
}
|
||||
|
||||
private async getChannelId(missionId: string): Promise<string | null> {
|
||||
const mission = await this.prisma.mission.findUnique({
|
||||
where: { id: missionId },
|
||||
select: { channelId: true },
|
||||
});
|
||||
return mission?.channelId ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
-
|
||||
@@ -1,20 +1,33 @@
|
||||
/**
|
||||
* This is not a production server yet!
|
||||
* This is only a minimal backend to get started.
|
||||
*/
|
||||
// OTel must be initialised before any other requires — do not move this line.
|
||||
import './tracing';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app/app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
if (process.env['NODE_ENV'] === 'production' && process.env['DEV_AUTH_BYPASS']) {
|
||||
throw new Error('DEV_AUTH_BYPASS must not be set in production');
|
||||
}
|
||||
|
||||
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||
app.useLogger(app.get(Logger));
|
||||
|
||||
const allowedOrigins =
|
||||
process.env['NODE_ENV'] === 'development'
|
||||
? ['http://localhost:4200', /https:\/\/.*\.ext-twitch\.tv$/]
|
||||
: [/https:\/\/.*\.ext-twitch\.tv$/];
|
||||
app.enableCors({ origin: allowedOrigins, credentials: true });
|
||||
|
||||
const globalPrefix = 'api';
|
||||
app.setGlobalPrefix(globalPrefix);
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
const port = process.env['PORT'] ?? 3000;
|
||||
await app.listen(port, '0.0.0.0');
|
||||
Logger.log(
|
||||
`🚀 Application is running on: http://localhost:${port}/${globalPrefix}`,
|
||||
|
||||
app.get(Logger).log(
|
||||
`Application running on http://localhost:${port}/${globalPrefix}`,
|
||||
'bootstrap'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
30
apps/api/src/tracing.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* OpenTelemetry bootstrap — must be imported before any other app module.
|
||||
* main.ts imports this as its first line.
|
||||
*/
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
|
||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
||||
import { ConsoleSpanExporter } from '@opentelemetry/sdk-trace-node';
|
||||
import { resourceFromAttributes } from '@opentelemetry/resources';
|
||||
|
||||
const exporter = process.env['OTEL_EXPORTER_OTLP_ENDPOINT']
|
||||
? new OTLPTraceExporter() // reads OTEL_EXPORTER_OTLP_ENDPOINT from env
|
||||
: new ConsoleSpanExporter();
|
||||
|
||||
const sdk = new NodeSDK({
|
||||
resource: resourceFromAttributes({ 'service.name': 'fog-expedition-api' }),
|
||||
traceExporter: exporter,
|
||||
instrumentations: [
|
||||
getNodeAutoInstrumentations({
|
||||
// fs instrumentation is too noisy for a server with many module loads.
|
||||
'@opentelemetry/instrumentation-fs': { enabled: false },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
sdk.start();
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
sdk.shutdown().finally(() => process.exit(0));
|
||||
});
|
||||
@@ -23,6 +23,12 @@
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/overlay/src/environments/environment.ts",
|
||||
"with": "apps/overlay/src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
|
||||
BIN
apps/overlay/public/avatars/IconItems_chineseFirecracker.webp
Executable file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/overlay/public/avatars/IconItems_flashlight.webp
Executable file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
apps/overlay/public/avatars/IconItems_key.webp
Executable file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
apps/overlay/public/avatars/IconItems_map.webp
Executable file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
apps/overlay/public/avatars/IconItems_medkit.webp
Executable file
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/overlay/public/avatars/IconItems_toolbox.webp
Executable file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
apps/overlay/public/avatars/K01_TheTrapper_Portrait.webp
Executable file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/overlay/public/avatars/K01_charPreview_portrait.webp
Executable file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
apps/overlay/public/avatars/K02_TheWraith_Portrait.webp
Executable file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/overlay/public/avatars/K03_TheHillbilly_Portrait.webp
Executable file
|
After Width: | Height: | Size: 98 KiB |
BIN
apps/overlay/public/avatars/K04_TheNurse_Portrait.webp
Executable file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/overlay/public/avatars/K05_TheShape_Portrait.webp
Executable file
|
After Width: | Height: | Size: 86 KiB |
BIN
apps/overlay/public/avatars/K06_TheHag_Portrait.webp
Executable file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/overlay/public/avatars/K07_TheDoctor_Portrait.webp
Executable file
|
After Width: | Height: | Size: 95 KiB |
BIN
apps/overlay/public/avatars/K08_TheHuntress_Portrait.webp
Executable file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/overlay/public/avatars/K09_TheCannibal_Portrait.webp
Executable file
|
After Width: | Height: | Size: 104 KiB |
BIN
apps/overlay/public/avatars/K10_TheNightmare_Portrait.webp
Executable file
|
After Width: | Height: | Size: 98 KiB |
BIN
apps/overlay/public/avatars/K11_ThePig_Portrait.webp
Executable file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/overlay/public/avatars/K12_TheClown_Portrait.webp
Executable file
|
After Width: | Height: | Size: 102 KiB |
BIN
apps/overlay/public/avatars/K13_TheSpirit_Portrait.webp
Executable file
|
After Width: | Height: | Size: 94 KiB |
BIN
apps/overlay/public/avatars/K14_TheLegion_Portrait.webp
Executable file
|
After Width: | Height: | Size: 96 KiB |
BIN
apps/overlay/public/avatars/K15_ThePlague_Portrait.webp
Executable file
|
After Width: | Height: | Size: 128 KiB |
BIN
apps/overlay/public/avatars/K16_TheGhostFace_Portrait.webp
Executable file
|
After Width: | Height: | Size: 84 KiB |
BIN
apps/overlay/public/avatars/K17_TheDemogorgon_Portrait.webp
Executable file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/overlay/public/avatars/K18_TheOni_Portrait.webp
Executable file
|
After Width: | Height: | Size: 128 KiB |
BIN
apps/overlay/public/avatars/K19_TheDeathslinger_Portrait.webp
Executable file
|
After Width: | Height: | Size: 92 KiB |
BIN
apps/overlay/public/avatars/K20_TheExecutioner_Portrait.webp
Executable file
|
After Width: | Height: | Size: 86 KiB |
BIN
apps/overlay/public/avatars/K21_TheBlight_Portrait.webp
Executable file
|
After Width: | Height: | Size: 114 KiB |
BIN
apps/overlay/public/avatars/K22_TheTwins_Portrait.webp
Executable file
|
After Width: | Height: | Size: 105 KiB |
BIN
apps/overlay/public/avatars/K23_TheTrickster_Portrait.webp
Executable file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/overlay/public/avatars/K24_TheNemesis_Portrait.webp
Executable file
|
After Width: | Height: | Size: 114 KiB |
BIN
apps/overlay/public/avatars/K25_TheCenobite_Portrait.webp
Executable file
|
After Width: | Height: | Size: 94 KiB |
BIN
apps/overlay/public/avatars/K26_TheArtist_Portrait.webp
Executable file
|
After Width: | Height: | Size: 102 KiB |
BIN
apps/overlay/public/avatars/K27_TheOnryo_Portrait.webp
Executable file
|
After Width: | Height: | Size: 72 KiB |
BIN
apps/overlay/public/avatars/K28_TheDredge_Portrait.webp
Executable file
|
After Width: | Height: | Size: 100 KiB |
BIN
apps/overlay/public/avatars/K29_TheMastermind_Portrait.webp
Executable file
|
After Width: | Height: | Size: 82 KiB |
BIN
apps/overlay/public/avatars/K30_TheKnight_Portrait.webp
Executable file
|
After Width: | Height: | Size: 108 KiB |
BIN
apps/overlay/public/avatars/K31_TheSkullMerchant_Portrait.webp
Executable file
|
After Width: | Height: | Size: 90 KiB |
BIN
apps/overlay/public/avatars/K32_TheSingularity_Portrait.webp
Executable file
|
After Width: | Height: | Size: 108 KiB |
BIN
apps/overlay/public/avatars/K33_TheXenomorph_Portrait.webp
Executable file
|
After Width: | Height: | Size: 98 KiB |
BIN
apps/overlay/public/avatars/K34_TheGoodGuy_Portrait.webp
Executable file
|
After Width: | Height: | Size: 81 KiB |
BIN
apps/overlay/public/avatars/K35_TheUnknown_Portrait.webp
Executable file
|
After Width: | Height: | Size: 92 KiB |
BIN
apps/overlay/public/avatars/K36_TheLich_Portrait.webp
Executable file
|
After Width: | Height: | Size: 97 KiB |
BIN
apps/overlay/public/avatars/K37_TheDarkLord_Portrait.webp
Executable file
|
After Width: | Height: | Size: 93 KiB |
BIN
apps/overlay/public/avatars/K38_TheHoundmaster_Portrait.webp
Executable file
|
After Width: | Height: | Size: 95 KiB |
BIN
apps/overlay/public/avatars/K39_TheGhoul_Portrait.webp
Executable file
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/overlay/public/avatars/K40_TheAnimatronic_Portrait.webp
Executable file
|
After Width: | Height: | Size: 90 KiB |
BIN
apps/overlay/public/avatars/K41_TheKrasue_Portrait.webp
Executable file
|
After Width: | Height: | Size: 108 KiB |
BIN
apps/overlay/public/avatars/S01_DwightFairfield_Portrait.webp
Executable file
|
After Width: | Height: | Size: 81 KiB |
BIN
apps/overlay/public/avatars/S02_MegThomas_Portrait.webp
Executable file
|
After Width: | Height: | Size: 72 KiB |
BIN
apps/overlay/public/avatars/S03_ClaudetteMorel_Portrait.webp
Executable file
|
After Width: | Height: | Size: 67 KiB |
BIN
apps/overlay/public/avatars/S04_JakePark_Portrait.webp
Executable file
|
After Width: | Height: | Size: 91 KiB |
BIN
apps/overlay/public/avatars/S05_NeaKarlsson_Portrait.webp
Executable file
|
After Width: | Height: | Size: 59 KiB |
BIN
apps/overlay/public/avatars/S06_LaurieStrode_Portrait.webp
Executable file
|
After Width: | Height: | Size: 84 KiB |
BIN
apps/overlay/public/avatars/S07_AceVisconti_Portrait.webp
Executable file
|
After Width: | Height: | Size: 86 KiB |
BIN
apps/overlay/public/avatars/S08_BillOverbeck_Portrait.webp
Executable file
|
After Width: | Height: | Size: 76 KiB |
BIN
apps/overlay/public/avatars/S09_FengMin_Portrait.webp
Executable file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/overlay/public/avatars/S10_DavidKing_Portrait.webp
Executable file
|
After Width: | Height: | Size: 87 KiB |
BIN
apps/overlay/public/avatars/S11_QuentinSmith_Portrait.webp
Executable file
|
After Width: | Height: | Size: 78 KiB |
BIN
apps/overlay/public/avatars/S12_DavidTapp_Portrait.webp
Executable file
|
After Width: | Height: | Size: 72 KiB |
BIN
apps/overlay/public/avatars/S13_KateDenson_Portrait.webp
Executable file
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/overlay/public/avatars/S14_AdamFrancis_Portrait.webp
Executable file
|
After Width: | Height: | Size: 63 KiB |
BIN
apps/overlay/public/avatars/S15_JeffJohansen_Portrait.webp
Executable file
|
After Width: | Height: | Size: 98 KiB |
BIN
apps/overlay/public/avatars/S16_JaneRomero_Portrait.webp
Executable file
|
After Width: | Height: | Size: 75 KiB |
BIN
apps/overlay/public/avatars/S17_AshWilliams_Portrait.webp
Executable file
|
After Width: | Height: | Size: 74 KiB |
BIN
apps/overlay/public/avatars/S18_NancyWheeler_Portrait.webp
Executable file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/overlay/public/avatars/S19_SteveHarrington_Portrait.webp
Executable file
|
After Width: | Height: | Size: 72 KiB |
BIN
apps/overlay/public/avatars/S20_YuiKimura_Portrait.webp
Executable file
|
After Width: | Height: | Size: 82 KiB |
BIN
apps/overlay/public/avatars/S21_ZarinaKassir_Portrait.webp
Executable file
|
After Width: | Height: | Size: 70 KiB |
BIN
apps/overlay/public/avatars/S22_CherylMason_Portrait.webp
Executable file
|
After Width: | Height: | Size: 77 KiB |
BIN
apps/overlay/public/avatars/S23_FelixRichter_Portrait.webp
Executable file
|
After Width: | Height: | Size: 64 KiB |
BIN
apps/overlay/public/avatars/S24_ElodieRakoto_Portrait.webp
Executable file
|
After Width: | Height: | Size: 104 KiB |
BIN
apps/overlay/public/avatars/S25_Yun-JinLee_Portrait.webp
Executable file
|
After Width: | Height: | Size: 90 KiB |
BIN
apps/overlay/public/avatars/S26_JillValentine_Portrait.webp
Executable file
|
After Width: | Height: | Size: 65 KiB |
BIN
apps/overlay/public/avatars/S27_LeonScottKennedy_Portrait.webp
Executable file
|
After Width: | Height: | Size: 68 KiB |
BIN
apps/overlay/public/avatars/S28_MikaelaReid_Portrait.webp
Executable file
|
After Width: | Height: | Size: 85 KiB |