commit 42d20cb0ed8d5ac9a981ac80ade12ae8833e96c0 Author: Hussar Date: Sun Apr 12 15:35:50 2026 +0000 first commit diff --git a/fog/.devcontainer/devcontainer.json b/fog/.devcontainer/devcontainer.json new file mode 100755 index 0000000..7a250b9 --- /dev/null +++ b/fog/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +{ + "name": "fog-expedition-devcontainer", + "dockerComposeFile": [ + "../docker-compose.yml", + "docker-compose.devcontainer.yml" + ], + "service": "workspace", + "workspaceFolder": "/workspaces/fog", + "runServices": ["workspace", "postgres", "redis"], + "shutdownAction": "stopCompose", + "remoteUser": "node", + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "nrwl.angular-console", + "angular.ng-template", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "Prisma.prisma" + ], + "settings": { + "editor.formatOnSave": true, + "files.eol": "\n" + } + } + }, + "forwardPorts": [3333, 4200, 5432, 6379], + "postCreateCommand": "npm install", + "postStartCommand": "echo 'Devcontainer ready. Run: npm run start:api and npm run start:panel'" +} diff --git a/fog/.devcontainer/docker-compose.devcontainer.yml b/fog/.devcontainer/docker-compose.devcontainer.yml new file mode 100755 index 0000000..c2e718b --- /dev/null +++ b/fog/.devcontainer/docker-compose.devcontainer.yml @@ -0,0 +1,13 @@ +version: "3.9" + +services: + workspace: + image: mcr.microsoft.com/devcontainers/javascript-node:1-20-bookworm + command: sleep infinity + volumes: + - ..:/workspaces/fog:cached + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy diff --git a/fog/.env b/fog/.env new file mode 100755 index 0000000..22169e1 --- /dev/null +++ b/fog/.env @@ -0,0 +1,6 @@ +DATABASE_URL="postgresql://postgres:postgres@postgres:5432/fog_expedition" +REDIS_URL="redis://redis:6379" +TWITCH_EXTENSION_CLIENT_ID="replace-me" +TWITCH_EXTENSION_SECRET="replace-me" +TWITCH_EXTENSION_OWNER_ID="replace-me" +PORT=3333 diff --git a/fog/.env.example b/fog/.env.example new file mode 100755 index 0000000..b852ed6 --- /dev/null +++ b/fog/.env.example @@ -0,0 +1,6 @@ +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/fog_expedition" +REDIS_URL="redis://localhost:6379" +TWITCH_EXTENSION_CLIENT_ID="replace-me" +TWITCH_EXTENSION_SECRET="replace-me" +TWITCH_EXTENSION_OWNER_ID="replace-me" +PORT=3333 diff --git a/fog/README.md b/fog/README.md new file mode 100755 index 0000000..d6866ac --- /dev/null +++ b/fog/README.md @@ -0,0 +1,36 @@ +# Fog Expedition Monorepo + +Nx-style monorepo scaffolding for: + +- `apps/twitch-extension-panel` (Angular viewer panel) +- `apps/broadcaster-config` (Angular broadcaster config view) +- `apps/api` (NestJS EBS API + tick engine) +- `libs/api-interfaces` (shared contracts) +- `libs/mission-logic` (encounter resolver + simulator CLI) +- `libs/encounter-library` (encounter content + schema validation) + +## Quick start + +1. Install Node.js 20+ +2. Run `npm install` +3. Copy `.env.example` to `.env` and fill values +4. Start infra with `docker compose up -d postgres redis` +5. Run `npm run start:api` and `npm run start:panel` + +## Useful commands + +- `npm run start:local-dev` - starts panel with Twitch mock mode enabled +- `npm run simulate` - runs mission balance simulation +- `nx run encounter-library:validate` - validates encounter JSON schema + +## Devcontainer + +- Reopen this folder in a devcontainer (VS Code/Cursor command: "Reopen in Container"). +- The container uses `.devcontainer/devcontainer.json` and starts: + - `workspace` service (Node 20) + - `postgres` + - `redis` +- Dependencies are installed automatically via `postCreateCommand` (`npm install`). +- After the container starts, run: + - `npm run start:api` + - `npm run start:panel` diff --git a/fog/apps/api/Dockerfile b/fog/apps/api/Dockerfile new file mode 100755 index 0000000..d38027e --- /dev/null +++ b/fog/apps/api/Dockerfile @@ -0,0 +1,10 @@ +FROM node:20-alpine AS base +WORKDIR /workspace + +COPY package.json ./ +RUN npm install --legacy-peer-deps && npm install --legacy-peer-deps --no-save tsx pino-http + +COPY . . + +EXPOSE 3333 +CMD ["npx", "tsx", "--tsconfig", "apps/api/tsconfig.app.json", "apps/api/src/main.ts"] diff --git a/fog/apps/api/prisma/schema.prisma b/fog/apps/api/prisma/schema.prisma new file mode 100755 index 0000000..2a14e1a --- /dev/null +++ b/fog/apps/api/prisma/schema.prisma @@ -0,0 +1,52 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(uuid()) + twitchUserId String @unique + createdAt DateTime @default(now()) + survivors Survivor[] +} + +model Survivor { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id]) + health Int @default(100) + stealth Int @default(10) + teamwork Int @default(10) + luck Int @default(10) + state String @default("Active") + sacrificedAt DateTime? + missions Mission[] +} + +model Mission { + id String @id @default(uuid()) + survivorId String + survivor Survivor @relation(fields: [survivorId], references: [id]) + status String + difficulty String + createdAt DateTime @default(now()) + completedAt DateTime? + logs MissionLog[] +} + +model MissionLog { + id String @id @default(uuid()) + missionId String + mission Mission @relation(fields: [missionId], references: [id]) + tickIndex Int + encounterKey String + renderedText String + rngDetails Json? + archivedAt DateTime? + + @@index([missionId, archivedAt]) +} diff --git a/fog/apps/api/project.json b/fog/apps/api/project.json new file mode 100755 index 0000000..590886f --- /dev/null +++ b/fog/apps/api/project.json @@ -0,0 +1,25 @@ +{ + "name": "api", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/api/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/api", + "main": "apps/api/src/main.ts", + "tsConfig": "apps/api/tsconfig.app.json", + "assets": ["apps/api/src/assets"] + } + }, + "serve": { + "executor": "@nx/js:node", + "options": { + "buildTarget": "api:build" + } + } + }, + "tags": ["scope:api"] +} diff --git a/fog/apps/api/src/app/app.module.ts b/fog/apps/api/src/app/app.module.ts new file mode 100755 index 0000000..52763ab --- /dev/null +++ b/fog/apps/api/src/app/app.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { ScheduleModule } from '@nestjs/schedule'; +import { LoggerModule } from 'nestjs-pino'; + +import { ChannelConfigModule } from './channel-config/channel-config.module'; +import { MetricsModule } from './metrics/metrics.module'; +import { MissionsModule } from './missions/missions.module'; +import { TickEngineModule } from './tick-engine/tick-engine.module'; +import { TwitchAuthModule } from './twitch-auth/twitch-auth.module'; +import { TwitchPubSubModule } from './twitch-pubsub/twitch-pubsub.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + ScheduleModule.forRoot(), + LoggerModule.forRoot({ + pinoHttp: { + level: process.env.LOG_LEVEL ?? 'info', + customProps: () => ({ service: 'fog-api' }) + } + }), + TwitchAuthModule, + MissionsModule, + TickEngineModule, + TwitchPubSubModule, + ChannelConfigModule, + MetricsModule + ] +}) +export class AppModule {} diff --git a/fog/apps/api/src/app/channel-config/channel-config.controller.ts b/fog/apps/api/src/app/channel-config/channel-config.controller.ts new file mode 100755 index 0000000..ad68186 --- /dev/null +++ b/fog/apps/api/src/app/channel-config/channel-config.controller.ts @@ -0,0 +1,17 @@ +import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common'; + +import { ChannelConfig } from '@fog-explorer/api-interfaces'; + +import { TwitchAuthGuard } from '../twitch-auth/twitch-auth.guard'; +import { ChannelConfigService } from './channel-config.service'; + +@Controller('channel') +@UseGuards(TwitchAuthGuard) +export class ChannelConfigController { + constructor(private readonly configService: ChannelConfigService) {} + + @Post('config') + saveChannelConfig(@Body() config: ChannelConfig, @Req() req: any) { + return this.configService.saveForChannel(req.viewer.channelId, config); + } +} diff --git a/fog/apps/api/src/app/channel-config/channel-config.module.ts b/fog/apps/api/src/app/channel-config/channel-config.module.ts new file mode 100755 index 0000000..219b5b0 --- /dev/null +++ b/fog/apps/api/src/app/channel-config/channel-config.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { ChannelConfigController } from './channel-config.controller'; +import { ChannelConfigService } from './channel-config.service'; + +@Module({ + providers: [ChannelConfigService], + controllers: [ChannelConfigController], + exports: [ChannelConfigService] +}) +export class ChannelConfigModule {} diff --git a/fog/apps/api/src/app/channel-config/channel-config.service.ts b/fog/apps/api/src/app/channel-config/channel-config.service.ts new file mode 100755 index 0000000..9ac9ecd --- /dev/null +++ b/fog/apps/api/src/app/channel-config/channel-config.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; + +import { ChannelConfig, MissionDifficulty } from '@fog-explorer/api-interfaces'; + +@Injectable() +export class ChannelConfigService { + private readonly inMemoryConfigs = new Map(); + + async saveForChannel(channelId: string, config: ChannelConfig): Promise { + const resolved: ChannelConfig = { + ...config, + channelId, + difficultyPreset: config.difficultyPreset ?? MissionDifficulty.Normal, + maxPartySize: config.maxPartySize ?? 4 + }; + this.inMemoryConfigs.set(channelId, resolved); + return resolved; + } + + async getForChannel(channelId: string): Promise { + return this.inMemoryConfigs.get(channelId) ?? null; + } +} diff --git a/fog/apps/api/src/app/metrics/metrics.controller.ts b/fog/apps/api/src/app/metrics/metrics.controller.ts new file mode 100755 index 0000000..84932ec --- /dev/null +++ b/fog/apps/api/src/app/metrics/metrics.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +import { MetricsService } from './metrics.service'; + +@Controller('metrics') +export class MetricsController { + constructor(private readonly metricsService: MetricsService) {} + + @Get() + getMetrics() { + return this.metricsService.snapshot(); + } +} diff --git a/fog/apps/api/src/app/metrics/metrics.module.ts b/fog/apps/api/src/app/metrics/metrics.module.ts new file mode 100755 index 0000000..85a2001 --- /dev/null +++ b/fog/apps/api/src/app/metrics/metrics.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { MetricsController } from './metrics.controller'; +import { MetricsService } from './metrics.service'; + +@Module({ + providers: [MetricsService], + controllers: [MetricsController], + exports: [MetricsService] +}) +export class MetricsModule {} diff --git a/fog/apps/api/src/app/metrics/metrics.service.ts b/fog/apps/api/src/app/metrics/metrics.service.ts new file mode 100755 index 0000000..346cac1 --- /dev/null +++ b/fog/apps/api/src/app/metrics/metrics.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class MetricsService { + private readonly counters = new Map(); + + increment(name: string, by = 1): void { + this.counters.set(name, (this.counters.get(name) ?? 0) + by); + } + + snapshot(): Record { + return Object.fromEntries(this.counters.entries()); + } +} diff --git a/fog/apps/api/src/app/missions/dto.ts b/fog/apps/api/src/app/missions/dto.ts new file mode 100755 index 0000000..5278870 --- /dev/null +++ b/fog/apps/api/src/app/missions/dto.ts @@ -0,0 +1,36 @@ +import { IsArray, IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +import { MissionDifficulty } from '@fog-explorer/api-interfaces'; + +export class StartMissionDto { + @IsString() + survivorId!: string; + + @IsEnum(MissionDifficulty) + difficulty!: MissionDifficulty; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + perkIds?: string[]; +} + +export class ChoosePerkDto { + @IsString() + missionId!: string; + + @IsString() + perkId!: string; +} + +export class StateQueryDto { + @IsString() + @IsOptional() + missionId?: string; + + @IsInt() + @Min(1) + @Max(20) + @IsOptional() + recentLogLines?: number = 12; +} diff --git a/fog/apps/api/src/app/missions/mission-log-archival.job.ts b/fog/apps/api/src/app/missions/mission-log-archival.job.ts new file mode 100755 index 0000000..563a9dd --- /dev/null +++ b/fog/apps/api/src/app/missions/mission-log-archival.job.ts @@ -0,0 +1,18 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; + +@Injectable() +export class MissionLogArchivalJob { + private readonly logger = new Logger(MissionLogArchivalJob.name); + + @Cron('0 3 * * *') + async archiveOldLogs(): Promise { + // Placeholder for a DB archival statement such as: + // UPDATE mission_logs SET archived_at = NOW() + // WHERE archived_at IS NULL AND mission_id IN (...) AND created_at < NOW() - interval '14 days' + this.logger.log({ + event: 'mission_log_archival_run', + message: 'Nightly archival job executed.' + }); + } +} diff --git a/fog/apps/api/src/app/missions/mission.store.ts b/fog/apps/api/src/app/missions/mission.store.ts new file mode 100755 index 0000000..da908ac --- /dev/null +++ b/fog/apps/api/src/app/missions/mission.store.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; + +import { MissionSnapshot } from '@fog-explorer/api-interfaces'; + +@Injectable() +export class MissionStore { + private readonly redis: Redis; + + constructor() { + this.redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379'); + } + + async getMission(missionId: string): Promise { + const raw = await this.redis.get(this.missionKey(missionId)); + return raw ? (JSON.parse(raw) as MissionSnapshot) : null; + } + + async setMission(mission: MissionSnapshot): Promise { + await this.redis.set( + this.missionKey(mission.id), + JSON.stringify(mission), + 'EX', + 60 * 60 + ); + await this.redis.sadd('active_missions', mission.id); + } + + async listActiveMissionIds(): Promise { + return this.redis.smembers('active_missions'); + } + + async getCachedMissionState(cacheKey: string): Promise { + const raw = await this.redis.get(cacheKey); + return raw ? (JSON.parse(raw) as MissionSnapshot) : null; + } + + async cacheMissionState( + cacheKey: string, + mission: MissionSnapshot, + ttlSeconds = 3 + ): Promise { + await this.redis.set(cacheKey, JSON.stringify(mission), 'EX', ttlSeconds); + } + + async getMissionTickSequence(missionId: string): Promise { + const raw = await this.redis.get(this.tickSeqKey(missionId)); + return Number(raw ?? 0); + } + + async setMissionTickSequence(missionId: string, index: number): Promise { + await this.redis.set(this.tickSeqKey(missionId), String(index), 'EX', 60 * 60); + } + + private missionKey(missionId: string): string { + return `active_mission:${missionId}`; + } + + private tickSeqKey(missionId: string): string { + return `active_mission:${missionId}:tick_seq`; + } +} diff --git a/fog/apps/api/src/app/missions/missions.controller.ts b/fog/apps/api/src/app/missions/missions.controller.ts new file mode 100755 index 0000000..014d0be --- /dev/null +++ b/fog/apps/api/src/app/missions/missions.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Get, Post, Query, Req, UseGuards } from '@nestjs/common'; + +import { TwitchAuthGuard } from '../twitch-auth/twitch-auth.guard'; +import { ChoosePerkDto, StartMissionDto, StateQueryDto } from './dto'; +import { MissionsService } from './missions.service'; + +@Controller('missions') +@UseGuards(TwitchAuthGuard) +export class MissionsController { + constructor(private readonly missionsService: MissionsService) {} + + @Post('start') + startMission(@Body() dto: StartMissionDto, @Req() req: any) { + return this.missionsService.startMission({ + survivorId: dto.survivorId, + difficulty: dto.difficulty, + channelId: req.viewer.channelId, + perkIds: dto.perkIds + }); + } + + @Get('state') + getState(@Query() query: StateQueryDto) { + return this.missionsService.getMissionState(query.missionId ?? '', query.recentLogLines); + } + + @Post('choose-perk') + choosePerk(@Body() dto: ChoosePerkDto) { + return this.missionsService.choosePerk(dto.missionId, dto.perkId); + } +} diff --git a/fog/apps/api/src/app/missions/missions.module.ts b/fog/apps/api/src/app/missions/missions.module.ts new file mode 100755 index 0000000..6b960a1 --- /dev/null +++ b/fog/apps/api/src/app/missions/missions.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; + +import { ChannelConfigModule } from '../channel-config/channel-config.module'; +import { TwitchPubSubModule } from '../twitch-pubsub/twitch-pubsub.module'; +import { MissionStore } from './mission.store'; +import { MissionLogArchivalJob } from './mission-log-archival.job'; +import { MissionsController } from './missions.controller'; +import { MissionsService } from './missions.service'; + +@Module({ + imports: [TwitchPubSubModule, ChannelConfigModule], + providers: [MissionsService, MissionStore, MissionLogArchivalJob], + controllers: [MissionsController], + exports: [MissionsService, MissionStore] +}) +export class MissionsModule {} diff --git a/fog/apps/api/src/app/missions/missions.service.ts b/fog/apps/api/src/app/missions/missions.service.ts new file mode 100755 index 0000000..8efa489 --- /dev/null +++ b/fog/apps/api/src/app/missions/missions.service.ts @@ -0,0 +1,107 @@ +import { randomUUID } from 'node:crypto'; + +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { + EncounterLogLine, + MissionDifficulty, + MissionSnapshot, + MissionState +} from '@fog-explorer/api-interfaces'; +import { resolveEncounter } from '@fog-explorer/mission-logic'; + +import { ChannelConfigService } from '../channel-config/channel-config.service'; +import { TwitchPubSubService } from '../twitch-pubsub/twitch-pubsub.service'; +import { MissionStore } from './mission.store'; + +@Injectable() +export class MissionsService { + constructor( + private readonly store: MissionStore, + private readonly pubsub: TwitchPubSubService, + private readonly channelConfig: ChannelConfigService + ) {} + + async startMission(input: { + survivorId: string; + channelId: string; + difficulty: MissionDifficulty; + perkIds?: string[]; + }): Promise { + const config = await this.channelConfig.getForChannel(input.channelId); + const missionId = randomUUID(); + const mission: MissionSnapshot = { + id: missionId, + channelId: input.channelId, + survivorId: input.survivorId, + state: MissionState.InProgress, + difficulty: config?.difficultyPreset ?? input.difficulty, + tickIndex: 0, + recentLog: [], + stats: { + health: 100, + stealth: 10, + teamwork: 10, + luck: 10 + }, + perkIds: input.perkIds ?? [] + }; + await this.store.setMission(mission); + return mission; + } + + async getMissionState(missionId: string, recentLogLines = 12): Promise { + const cacheKey = `mission_state:${missionId}`; + const cached = await this.store.getCachedMissionState(cacheKey); + if (cached) { + return cached; + } + + const mission = await this.store.getMission(missionId); + if (!mission) { + throw new NotFoundException(`Mission ${missionId} not found`); + } + mission.recentLog = mission.recentLog.slice(-recentLogLines); + await this.store.cacheMissionState(cacheKey, mission, 3); + return mission; + } + + async resolveMissionTick(missionId: string, tickIndex: number): Promise { + const mission = await this.store.getMission(missionId); + if (!mission || mission.state !== MissionState.InProgress) { + return null; + } + + const lastResolved = await this.store.getMissionTickSequence(missionId); + if (lastResolved >= tickIndex) { + return mission; + } + + const result = resolveEncounter({ + stats: mission.stats, + difficulty: mission.difficulty, + perkIds: mission.perkIds, + tickIndex + }); + const logLine: EncounterLogLine = { + sequence: tickIndex, + event: result.outcome, + text: result.text, + successChance: result.successChance, + roll: result.roll + }; + mission.tickIndex = tickIndex; + mission.recentLog = [...mission.recentLog, logLine].slice(-15); + mission.state = result.nextState; + mission.stats = result.nextStats; + + await this.store.setMission(mission); + await this.store.setMissionTickSequence(missionId, tickIndex); + await this.pubsub.publishMissionUpdate(mission.channelId, mission); + return mission; + } + + async choosePerk(_missionId: string, _perkId: string): Promise<{ accepted: boolean }> { + return { accepted: true }; + } +} diff --git a/fog/apps/api/src/app/tick-engine/tick-engine.module.ts b/fog/apps/api/src/app/tick-engine/tick-engine.module.ts new file mode 100755 index 0000000..96b93e6 --- /dev/null +++ b/fog/apps/api/src/app/tick-engine/tick-engine.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { MissionsModule } from '../missions/missions.module'; +import { TickService } from './tick.service'; + +@Module({ + imports: [MissionsModule], + providers: [TickService] +}) +export class TickEngineModule {} diff --git a/fog/apps/api/src/app/tick-engine/tick.service.ts b/fog/apps/api/src/app/tick-engine/tick.service.ts new file mode 100755 index 0000000..0d7782e --- /dev/null +++ b/fog/apps/api/src/app/tick-engine/tick.service.ts @@ -0,0 +1,63 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import Redis from 'ioredis'; + +import { MissionStore } from '../missions/mission.store'; +import { MissionsService } from '../missions/missions.service'; + +@Injectable() +export class TickService { + private readonly logger = new Logger(TickService.name); + private readonly redis: Redis; + + constructor( + private readonly missionStore: MissionStore, + private readonly missionsService: MissionsService + ) { + this.redis = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379'); + } + + @Cron(CronExpression.EVERY_MINUTE) + async runGlobalTick(): Promise { + const tickStarted = Date.now(); + const tickIndex = Math.floor(tickStarted / 60000); + const lockKey = 'tick_lock'; + const lockToken = String(tickStarted); + + const lockAcquired = await this.redis.set(lockKey, lockToken, 'NX', 'EX', 50); + if (!lockAcquired) { + this.logger.warn({ + event: 'tick_lock_busy', + tickIndex + }); + return; + } + + try { + const missionIds = await this.missionStore.listActiveMissionIds(); + let processed = 0; + for (const missionId of missionIds) { + await this.missionsService.resolveMissionTick(missionId, tickIndex); + processed += 1; + } + const durationMs = Date.now() - tickStarted; + this.logger.log({ + event: 'tick_complete', + tickIndex, + durationMs, + activeMissionCount: missionIds.length, + processedMissionCount: processed + }); + } finally { + const current = await this.redis.get(lockKey); + if (current === lockToken) { + await this.redis.del(lockKey); + } else { + this.logger.warn({ + event: 'tick_lock_expired_before_release', + tickIndex + }); + } + } + } +} diff --git a/fog/apps/api/src/app/twitch-auth/twitch-auth.guard.ts b/fog/apps/api/src/app/twitch-auth/twitch-auth.guard.ts new file mode 100755 index 0000000..63490ac --- /dev/null +++ b/fog/apps/api/src/app/twitch-auth/twitch-auth.guard.ts @@ -0,0 +1,51 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; + +import { TwitchSecretService } from './twitch-secret.service'; + +@Injectable() +export class TwitchAuthGuard implements CanActivate { + private readonly jwt = new JwtService(); + + constructor(private readonly secretService: TwitchSecretService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + const authHeader = req.headers.authorization ?? ''; + const token = authHeader.startsWith('Bearer ') + ? authHeader.replace('Bearer ', '') + : ''; + + if (!token) { + throw new UnauthorizedException('Missing Twitch extension JWT'); + } + + const payload = await this.verifyWithRotation(token); + req.viewer = { + userId: payload.user_id ?? payload.opaque_user_id ?? 'anonymous', + channelId: payload.channel_id ?? 'unknown' + }; + return true; + } + + private async verifyWithRotation(token: string): Promise> { + const firstSecret = await this.secretService.getCurrentSecret(false); + try { + return this.jwt.verify(token, { secret: firstSecret }) as Record; + } catch { + const refreshedSecret = await this.secretService.getCurrentSecret(true); + try { + return this.jwt.verify(token, { + secret: refreshedSecret + }) as Record; + } catch { + throw new UnauthorizedException('Invalid Twitch extension JWT'); + } + } + } +} diff --git a/fog/apps/api/src/app/twitch-auth/twitch-auth.module.ts b/fog/apps/api/src/app/twitch-auth/twitch-auth.module.ts new file mode 100755 index 0000000..22289ca --- /dev/null +++ b/fog/apps/api/src/app/twitch-auth/twitch-auth.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; + +import { TwitchAuthGuard } from './twitch-auth.guard'; +import { TwitchSecretService } from './twitch-secret.service'; + +@Module({ + imports: [ConfigModule], + providers: [TwitchSecretService, TwitchAuthGuard], + exports: [TwitchAuthGuard, TwitchSecretService] +}) +export class TwitchAuthModule {} diff --git a/fog/apps/api/src/app/twitch-auth/twitch-secret.service.ts b/fog/apps/api/src/app/twitch-auth/twitch-secret.service.ts new file mode 100755 index 0000000..f3728cb --- /dev/null +++ b/fog/apps/api/src/app/twitch-auth/twitch-secret.service.ts @@ -0,0 +1,39 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +interface CachedSecret { + value: string; + expiresAt: number; +} + +@Injectable() +export class TwitchSecretService { + private readonly logger = new Logger(TwitchSecretService.name); + private cachedSecret: CachedSecret | null = null; + + constructor(private readonly configService: ConfigService) {} + + async getCurrentSecret(forceRefresh = false): Promise { + const now = Date.now(); + if (!forceRefresh && this.cachedSecret && this.cachedSecret.expiresAt > now) { + return this.cachedSecret.value; + } + + const refreshed = await this.fetchSecretFromTwitchApi(); + this.cachedSecret = { + value: refreshed, + expiresAt: now + 5 * 60 * 1000 + }; + return refreshed; + } + + private async fetchSecretFromTwitchApi(): Promise { + // Placeholder for real Twitch API call. + // For local development this falls back to env secret. + this.logger.warn({ + event: 'twitch_secret_refetch', + message: 'Refreshing Twitch extension secret after verification failure.' + }); + return this.configService.get('TWITCH_EXTENSION_SECRET', 'replace-me'); + } +} diff --git a/fog/apps/api/src/app/twitch-pubsub/twitch-pubsub.module.ts b/fog/apps/api/src/app/twitch-pubsub/twitch-pubsub.module.ts new file mode 100755 index 0000000..9f6275e --- /dev/null +++ b/fog/apps/api/src/app/twitch-pubsub/twitch-pubsub.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { TwitchPubSubService } from './twitch-pubsub.service'; + +@Module({ + providers: [TwitchPubSubService], + exports: [TwitchPubSubService] +}) +export class TwitchPubSubModule {} diff --git a/fog/apps/api/src/app/twitch-pubsub/twitch-pubsub.service.ts b/fog/apps/api/src/app/twitch-pubsub/twitch-pubsub.service.ts new file mode 100755 index 0000000..dbb7436 --- /dev/null +++ b/fog/apps/api/src/app/twitch-pubsub/twitch-pubsub.service.ts @@ -0,0 +1,25 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { MissionSnapshot } from '@fog-explorer/api-interfaces'; + +@Injectable() +export class TwitchPubSubService { + private readonly logger = new Logger(TwitchPubSubService.name); + + async publishMissionUpdate(channelId: string, mission: MissionSnapshot): Promise { + const payload = { + sequence: mission.tickIndex, + missionId: mission.id, + recentLog: mission.recentLog.slice(-12), + state: mission.state + }; + + // Placeholder: replace with Twitch EBS PubSub API call. + this.logger.log({ + event: 'pubsub_publish', + channelId, + missionId: mission.id, + bytes: JSON.stringify(payload).length + }); + } +} diff --git a/fog/apps/api/src/assets/.gitkeep b/fog/apps/api/src/assets/.gitkeep new file mode 100755 index 0000000..d3f5a12 --- /dev/null +++ b/fog/apps/api/src/assets/.gitkeep @@ -0,0 +1 @@ + diff --git a/fog/apps/api/src/main.ts b/fog/apps/api/src/main.ts new file mode 100755 index 0000000..6b7bc24 --- /dev/null +++ b/fog/apps/api/src/main.ts @@ -0,0 +1,23 @@ +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { Logger } from 'nestjs-pino'; + +import { AppModule } from './app/app.module'; + +async function bootstrap(): Promise { + const app = await NestFactory.create(AppModule, { bufferLogs: true }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true + }) + ); + + app.useLogger(app.get(Logger)); + + const port = Number(process.env.PORT ?? 3333); + await app.listen(port); +} + +bootstrap(); diff --git a/fog/apps/api/tsconfig.app.json b/fog/apps/api/tsconfig.app.json new file mode 100755 index 0000000..c2aa86d --- /dev/null +++ b/fog/apps/api/tsconfig.app.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "CommonJS", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/fog/apps/broadcaster-config/project.json b/fog/apps/broadcaster-config/project.json new file mode 100755 index 0000000..39ea480 --- /dev/null +++ b/fog/apps/broadcaster-config/project.json @@ -0,0 +1,26 @@ +{ + "name": "broadcaster-config", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/broadcaster-config/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@nx/angular:application", + "options": { + "outputPath": "dist/apps/broadcaster-config", + "index": "apps/broadcaster-config/src/index.html", + "browser": "apps/broadcaster-config/src/main.ts", + "tsConfig": "apps/broadcaster-config/tsconfig.app.json", + "assets": [], + "styles": [] + } + }, + "serve": { + "executor": "@nx/angular:dev-server", + "options": { + "buildTarget": "broadcaster-config:build" + } + } + }, + "tags": ["scope:panel"] +} diff --git a/fog/apps/broadcaster-config/src/index.html b/fog/apps/broadcaster-config/src/index.html new file mode 100755 index 0000000..97a6e34 --- /dev/null +++ b/fog/apps/broadcaster-config/src/index.html @@ -0,0 +1,11 @@ + + + + + + Fog Broadcaster Config + + + + + diff --git a/fog/apps/broadcaster-config/src/main.ts b/fog/apps/broadcaster-config/src/main.ts new file mode 100755 index 0000000..833d668 --- /dev/null +++ b/fog/apps/broadcaster-config/src/main.ts @@ -0,0 +1,45 @@ +import { CommonModule } from '@angular/common'; +import { HttpClient, provideHttpClient } from '@angular/common/http'; +import { Component, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { bootstrapApplication } from '@angular/platform-browser'; + +import { MissionDifficulty } from '@fog-explorer/api-interfaces'; + +@Component({ + selector: 'fog-broadcaster-config', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` +

Fog Broadcaster Config

+ + + + + + ` +}) +class BroadcasterConfigComponent { + private readonly http = inject(HttpClient); + + readonly difficulties = Object.values(MissionDifficulty); + difficulty: MissionDifficulty = MissionDifficulty.Normal; + maxPartySize = 4; + + save(): void { + void this.http + .post('/channel/config', { + channelId: 'from-jwt', + difficultyPreset: this.difficulty, + maxPartySize: this.maxPartySize, + featureFlags: {} + }) + .subscribe(); + } +} + +bootstrapApplication(BroadcasterConfigComponent, { + providers: [provideHttpClient()] +}).catch((error) => console.error(error)); diff --git a/fog/apps/broadcaster-config/tsconfig.app.json b/fog/apps/broadcaster-config/tsconfig.app.json new file mode 100755 index 0000000..e879e64 --- /dev/null +++ b/fog/apps/broadcaster-config/tsconfig.app.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [] + }, + "include": ["src/**/*.ts"] +} diff --git a/fog/apps/twitch-extension-panel/Dockerfile b/fog/apps/twitch-extension-panel/Dockerfile new file mode 100755 index 0000000..c6edbd8 --- /dev/null +++ b/fog/apps/twitch-extension-panel/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS build +WORKDIR /workspace + +COPY package.json ./ +RUN npm install + +COPY . . +RUN npx nx build twitch-extension-panel + +FROM nginx:alpine +COPY --from=build /workspace/dist/apps/twitch-extension-panel/browser /usr/share/nginx/html +EXPOSE 80 diff --git a/fog/apps/twitch-extension-panel/project.json b/fog/apps/twitch-extension-panel/project.json new file mode 100755 index 0000000..4a5a11d --- /dev/null +++ b/fog/apps/twitch-extension-panel/project.json @@ -0,0 +1,26 @@ +{ + "name": "twitch-extension-panel", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/twitch-extension-panel/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@nx/angular:application", + "options": { + "outputPath": "dist/apps/twitch-extension-panel", + "index": "apps/twitch-extension-panel/src/index.html", + "browser": "apps/twitch-extension-panel/src/main.ts", + "tsConfig": "apps/twitch-extension-panel/tsconfig.app.json", + "assets": [], + "styles": ["apps/twitch-extension-panel/src/styles.css"] + } + }, + "serve": { + "executor": "@nx/angular:dev-server", + "options": { + "buildTarget": "twitch-extension-panel:build" + } + } + }, + "tags": ["scope:panel"] +} diff --git a/fog/apps/twitch-extension-panel/src/app/live-log.component.ts b/fog/apps/twitch-extension-panel/src/app/live-log.component.ts new file mode 100755 index 0000000..50be5e6 --- /dev/null +++ b/fog/apps/twitch-extension-panel/src/app/live-log.component.ts @@ -0,0 +1,28 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; + +import { EncounterLogLine } from '@fog-explorer/api-interfaces'; + +@Component({ + selector: 'fog-live-log', + standalone: true, + imports: [CommonModule], + template: ` +
+

Live Log

+
+

#{{ line.sequence }} - {{ line.text }}

+
+
+ `, + styles: [ + ` + .panel-card { padding: 0.75rem; background: #1a1e2a; border-radius: 8px; } + .log-list { max-height: 220px; overflow: auto; } + p { margin: 0 0 0.5rem; font-size: 0.9rem; } + ` + ] +}) +export class LiveLogComponent { + @Input({ required: true }) lines: EncounterLogLine[] = []; +} diff --git a/fog/apps/twitch-extension-panel/src/app/panel-shell.component.ts b/fog/apps/twitch-extension-panel/src/app/panel-shell.component.ts new file mode 100755 index 0000000..60456b6 --- /dev/null +++ b/fog/apps/twitch-extension-panel/src/app/panel-shell.component.ts @@ -0,0 +1,66 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit, computed, inject } from '@angular/core'; + +import { MissionDifficulty } from '@fog-explorer/api-interfaces'; + +import { EbsApiService } from './services/ebs-api.service'; +import { MissionStateStore } from './services/mission-state.store'; +import { PubSubService } from './services/pubsub.service'; +import { TwitchAuthService } from './services/twitch-auth.service'; +import { LiveLogComponent } from './live-log.component'; +import { SurvivorStatusComponent } from './survivor-status.component'; + +@Component({ + selector: 'fog-panel-shell', + standalone: true, + imports: [CommonModule, LiveLogComponent, SurvivorStatusComponent], + template: ` +
+
+

Fog Expedition

+

reconnecting...

+
+ + + +
+ `, + styles: [ + ` + .layout { display: grid; gap: 0.75rem; padding: 0.75rem; } + button { background: #6d28d9; border: 0; color: white; padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; } + h2, p { margin: 0; } + ` + ] +}) +export class PanelShellComponent implements OnInit { + private readonly auth = inject(TwitchAuthService); + private readonly api = inject(EbsApiService); + private readonly store = inject(MissionStateStore); + private readonly pubsub = inject(PubSubService); + + readonly mission = this.store.mission; + readonly reconnecting = this.store.reconnecting; + readonly logLines = computed(() => this.store.logLines()); + + ngOnInit(): void { + this.auth.init(); + } + + async startMission(): Promise { + const auth = this.auth.auth(); + if (!auth) { + return; + } + const mission = await this.api.startMission(auth.token, { + survivorId: auth.userId, + difficulty: MissionDifficulty.Normal + }); + this.store.setMission(mission); + this.pubsub.subscribeToMissionUpdates(`mission.${mission.id}`, async () => { + this.store.setReconnecting(true); + const fresh = await this.api.getMissionState(auth.token, mission.id); + this.store.setMission(fresh); + }); + } +} diff --git a/fog/apps/twitch-extension-panel/src/app/services/ebs-api.service.ts b/fog/apps/twitch-extension-panel/src/app/services/ebs-api.service.ts new file mode 100755 index 0000000..bb6c0cb --- /dev/null +++ b/fog/apps/twitch-extension-panel/src/app/services/ebs-api.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@angular/core'; + +import { MissionSnapshot } from '@fog-explorer/api-interfaces'; + +import { environment } from '../../environments/environment'; + +@Injectable({ providedIn: 'root' }) +export class EbsApiService { + async startMission(token: string, body: { survivorId: string; difficulty: string }) { + return this.fetchWithRetry('/missions/start', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); + } + + async getMissionState(token: string, missionId: string): Promise { + return this.fetchWithRetry(`/missions/state?missionId=${missionId}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}` + } + }); + } + + private async fetchWithRetry(path: string, init: RequestInit, attempt = 0): Promise { + try { + const response = await fetch(`${environment.apiBaseUrl}${path}`, init); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return (await response.json()) as T; + } catch (error) { + if (attempt >= 4) { + throw error; + } + const delayMs = Math.min(5000, Math.round(350 * 2 ** attempt + Math.random() * 150)); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return this.fetchWithRetry(path, init, attempt + 1); + } + } +} diff --git a/fog/apps/twitch-extension-panel/src/app/services/mission-state.store.ts b/fog/apps/twitch-extension-panel/src/app/services/mission-state.store.ts new file mode 100755 index 0000000..2c26104 --- /dev/null +++ b/fog/apps/twitch-extension-panel/src/app/services/mission-state.store.ts @@ -0,0 +1,19 @@ +import { Injectable, computed, signal } from '@angular/core'; + +import { MissionSnapshot } from '@fog-explorer/api-interfaces'; + +@Injectable({ providedIn: 'root' }) +export class MissionStateStore { + readonly mission = signal(null); + readonly reconnecting = signal(false); + readonly logLines = computed(() => this.mission()?.recentLog ?? []); + + setMission(snapshot: MissionSnapshot): void { + this.mission.set(snapshot); + this.reconnecting.set(false); + } + + setReconnecting(value: boolean): void { + this.reconnecting.set(value); + } +} diff --git a/fog/apps/twitch-extension-panel/src/app/services/pubsub.service.ts b/fog/apps/twitch-extension-panel/src/app/services/pubsub.service.ts new file mode 100755 index 0000000..09a69b7 --- /dev/null +++ b/fog/apps/twitch-extension-panel/src/app/services/pubsub.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@angular/core'; + +import { MissionSnapshot } from '@fog-explorer/api-interfaces'; + +import { MissionStateStore } from './mission-state.store'; +import { TwitchAuthService } from './twitch-auth.service'; + +interface MissionDeltaMessage { + sequence: number; + missionId: string; + state: string; + recentLog: MissionSnapshot['recentLog']; +} + +@Injectable({ providedIn: 'root' }) +export class PubSubService { + private lastSequence = 0; + + constructor( + private readonly auth: TwitchAuthService, + private readonly store: MissionStateStore + ) {} + + subscribeToMissionUpdates(topic: string, fullRefetch: () => Promise): void { + this.auth.onPubSub(topic, async (_target, _contentType, message) => { + const parsed = JSON.parse(message) as MissionDeltaMessage; + if (parsed.sequence !== this.lastSequence + 1 && this.lastSequence !== 0) { + await fullRefetch(); + } else { + const current = this.store.mission(); + if (current && current.id === parsed.missionId) { + this.store.setMission({ + ...current, + state: parsed.state as MissionSnapshot['state'], + recentLog: parsed.recentLog.slice(-15), + tickIndex: parsed.sequence + }); + } + } + this.lastSequence = parsed.sequence; + }); + } +} diff --git a/fog/apps/twitch-extension-panel/src/app/services/twitch-auth.service.ts b/fog/apps/twitch-extension-panel/src/app/services/twitch-auth.service.ts new file mode 100755 index 0000000..51e03af --- /dev/null +++ b/fog/apps/twitch-extension-panel/src/app/services/twitch-auth.service.ts @@ -0,0 +1,47 @@ +import { Injectable, signal } from '@angular/core'; + +import { twitchExtMock, TwitchExtLike } from '../../mocks/twitch-ext.mock'; +import { environment } from '../../environments/environment'; + +declare global { + interface Window { + Twitch?: { + ext?: TwitchExtLike; + }; + } +} + +export interface TwitchAuthState { + token: string; + userId: string; +} + +@Injectable({ providedIn: 'root' }) +export class TwitchAuthService { + readonly auth = signal(null); + + init(): void { + const ext = this.getExt(); + ext.onAuthorized(({ token, userId }) => { + this.auth.set({ token, userId }); + }); + } + + onPubSub( + topic: string, + callback: (target: string, contentType: string, message: string) => void + ): void { + this.getExt().listen(topic, callback); + } + + private getExt(): TwitchExtLike { + if (environment.mock) { + return twitchExtMock; + } + const ext = window.Twitch?.ext; + if (!ext) { + return twitchExtMock; + } + return ext; + } +} diff --git a/fog/apps/twitch-extension-panel/src/app/survivor-status.component.ts b/fog/apps/twitch-extension-panel/src/app/survivor-status.component.ts new file mode 100755 index 0000000..c5eb536 --- /dev/null +++ b/fog/apps/twitch-extension-panel/src/app/survivor-status.component.ts @@ -0,0 +1,35 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; + +import { MissionSnapshot } from '@fog-explorer/api-interfaces'; + +@Component({ + selector: 'fog-survivor-status', + standalone: true, + imports: [CommonModule], + template: ` +
+

Survivor Status

+ +

Mission: {{ mission.id }}

+

State: {{ mission.state }}

+

Health: {{ mission.stats.health }}

+

Stealth: {{ mission.stats.stealth }}

+

Teamwork: {{ mission.stats.teamwork }}

+

Luck: {{ mission.stats.luck }}

+
+ +

No active mission.

+
+
+ `, + styles: [ + ` + .panel-card { padding: 0.75rem; background: #1a1e2a; border-radius: 8px; } + p { margin: 0 0 0.25rem; font-size: 0.9rem; } + ` + ] +}) +export class SurvivorStatusComponent { + @Input() mission: MissionSnapshot | null = null; +} diff --git a/fog/apps/twitch-extension-panel/src/environments/environment.prod.ts b/fog/apps/twitch-extension-panel/src/environments/environment.prod.ts new file mode 100755 index 0000000..e0a052f --- /dev/null +++ b/fog/apps/twitch-extension-panel/src/environments/environment.prod.ts @@ -0,0 +1,5 @@ +export const environment = { + production: true, + mock: false, + apiBaseUrl: '/api' +}; diff --git a/fog/apps/twitch-extension-panel/src/environments/environment.ts b/fog/apps/twitch-extension-panel/src/environments/environment.ts new file mode 100755 index 0000000..c72410d --- /dev/null +++ b/fog/apps/twitch-extension-panel/src/environments/environment.ts @@ -0,0 +1,5 @@ +export const environment = { + production: false, + mock: true, + apiBaseUrl: 'http://localhost:3333' +}; diff --git a/fog/apps/twitch-extension-panel/src/index.html b/fog/apps/twitch-extension-panel/src/index.html new file mode 100755 index 0000000..e56678e --- /dev/null +++ b/fog/apps/twitch-extension-panel/src/index.html @@ -0,0 +1,11 @@ + + + + + + Fog Expedition Panel + + + + + diff --git a/fog/apps/twitch-extension-panel/src/main.ts b/fog/apps/twitch-extension-panel/src/main.ts new file mode 100755 index 0000000..a056bcd --- /dev/null +++ b/fog/apps/twitch-extension-panel/src/main.ts @@ -0,0 +1,7 @@ +import { bootstrapApplication } from '@angular/platform-browser'; + +import { PanelShellComponent } from './app/panel-shell.component'; + +bootstrapApplication(PanelShellComponent).catch((error) => { + console.error(error); +}); diff --git a/fog/apps/twitch-extension-panel/src/mocks/twitch-ext.mock.ts b/fog/apps/twitch-extension-panel/src/mocks/twitch-ext.mock.ts new file mode 100755 index 0000000..73c6496 --- /dev/null +++ b/fog/apps/twitch-extension-panel/src/mocks/twitch-ext.mock.ts @@ -0,0 +1,25 @@ +export interface TwitchExtLike { + onAuthorized(callback: (auth: { token: string; userId: string }) => void): void; + onContext(callback: (context: Record) => void): void; + onVisibilityChanged(callback: (isVisible: boolean, context: unknown) => void): void; + listen(topic: string, callback: (target: string, contentType: string, message: string) => void): void; +} + +export const twitchExtMock: TwitchExtLike = { + onAuthorized(callback) { + callback({ + token: + 'mock.jwt.token', + userId: 'U_mock_viewer' + }); + }, + onContext(callback) { + callback({ theme: 'dark' }); + }, + onVisibilityChanged(callback) { + callback(true, {}); + }, + listen(_topic, _callback) { + // No-op in local mock mode. + } +}; diff --git a/fog/apps/twitch-extension-panel/src/styles.css b/fog/apps/twitch-extension-panel/src/styles.css new file mode 100755 index 0000000..9a90408 --- /dev/null +++ b/fog/apps/twitch-extension-panel/src/styles.css @@ -0,0 +1,10 @@ +:root { + color-scheme: dark; + font-family: Inter, system-ui, sans-serif; +} + +body { + margin: 0; + background: #0f1117; + color: #f3f4f6; +} diff --git a/fog/apps/twitch-extension-panel/tsconfig.app.json b/fog/apps/twitch-extension-panel/tsconfig.app.json new file mode 100755 index 0000000..e879e64 --- /dev/null +++ b/fog/apps/twitch-extension-panel/tsconfig.app.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [] + }, + "include": ["src/**/*.ts"] +} diff --git a/fog/docker-compose.yml b/fog/docker-compose.yml new file mode 100755 index 0000000..0fddaab --- /dev/null +++ b/fog/docker-compose.yml @@ -0,0 +1,47 @@ +services: + postgres: + image: postgres:16 + container_name: fog-postgres + restart: unless-stopped + environment: + POSTGRES_DB: fog_expedition + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - '5432:5432' + volumes: + - fog_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres -d fog_expedition'] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7 + container_name: fog-redis + restart: unless-stopped + ports: + - '6379:6379' + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 3s + retries: 10 + + api: + build: + context: . + dockerfile: apps/api/Dockerfile + env_file: + - .env + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + ports: + - '3333:3333' + +volumes: + fog_postgres_data: diff --git a/fog/libs/api-interfaces/project.json b/fog/libs/api-interfaces/project.json new file mode 100755 index 0000000..ac68335 --- /dev/null +++ b/fog/libs/api-interfaces/project.json @@ -0,0 +1,18 @@ +{ + "name": "api-interfaces", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/api-interfaces/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/api-interfaces", + "main": "libs/api-interfaces/src/index.ts", + "tsConfig": "libs/api-interfaces/tsconfig.lib.json" + } + } + }, + "tags": ["scope:shared"] +} diff --git a/fog/libs/api-interfaces/src/index.ts b/fog/libs/api-interfaces/src/index.ts new file mode 100755 index 0000000..405a8e0 --- /dev/null +++ b/fog/libs/api-interfaces/src/index.ts @@ -0,0 +1,5 @@ +export * from './lib/survivor'; +export * from './lib/mission'; +export * from './lib/perk'; +export * from './lib/encounter'; +export * from './lib/channel-config'; diff --git a/fog/libs/api-interfaces/src/lib/channel-config.ts b/fog/libs/api-interfaces/src/lib/channel-config.ts new file mode 100755 index 0000000..ec8685b --- /dev/null +++ b/fog/libs/api-interfaces/src/lib/channel-config.ts @@ -0,0 +1,8 @@ +import { MissionDifficulty } from './mission'; + +export interface ChannelConfig { + channelId: string; + difficultyPreset: MissionDifficulty; + maxPartySize: number; + featureFlags: Record; +} diff --git a/fog/libs/api-interfaces/src/lib/encounter.ts b/fog/libs/api-interfaces/src/lib/encounter.ts new file mode 100755 index 0000000..b694ee6 --- /dev/null +++ b/fog/libs/api-interfaces/src/lib/encounter.ts @@ -0,0 +1,19 @@ +import { MissionDifficulty, MissionState } from './mission'; +import { SurvivorStats } from './survivor'; + +export interface EncounterResult { + outcome: 'success' | 'injury' | 'sacrifice'; + text: string; + successChance: number; + roll: number; + nextState: MissionState; + nextStats: SurvivorStats; +} + +export interface ResolveEncounterInput { + stats: SurvivorStats; + difficulty: MissionDifficulty; + perkIds: string[]; + tickIndex: number; + seed?: number; +} diff --git a/fog/libs/api-interfaces/src/lib/mission.ts b/fog/libs/api-interfaces/src/lib/mission.ts new file mode 100755 index 0000000..7d177ff --- /dev/null +++ b/fog/libs/api-interfaces/src/lib/mission.ts @@ -0,0 +1,35 @@ +import { SurvivorStats } from './survivor'; + +export enum MissionState { + Lobby = 'Lobby', + InProgress = 'InProgress', + Completed = 'Completed', + Failed = 'Failed' +} + +export enum MissionDifficulty { + Easy = 'Easy', + Normal = 'Normal', + Hard = 'Hard', + Nightmare = 'Nightmare' +} + +export interface EncounterLogLine { + sequence: number; + event: string; + text: string; + successChance: number; + roll: number; +} + +export interface MissionSnapshot { + id: string; + channelId: string; + survivorId: string; + state: MissionState; + difficulty: MissionDifficulty; + tickIndex: number; + recentLog: EncounterLogLine[]; + stats: SurvivorStats; + perkIds: string[]; +} diff --git a/fog/libs/api-interfaces/src/lib/perk.ts b/fog/libs/api-interfaces/src/lib/perk.ts new file mode 100755 index 0000000..7c82f53 --- /dev/null +++ b/fog/libs/api-interfaces/src/lib/perk.ts @@ -0,0 +1,19 @@ +export enum ModifierKind { + Additive = 'additive', + Multiplicative = 'multiplicative', + FlatReroll = 'flat_reroll' +} + +export interface PerkModifier { + id: string; + kind: ModifierKind; + value: number; + teamPerk?: boolean; +} + +export interface Perk { + id: string; + name: string; + description: string; + modifiers: PerkModifier[]; +} diff --git a/fog/libs/api-interfaces/src/lib/survivor.ts b/fog/libs/api-interfaces/src/lib/survivor.ts new file mode 100755 index 0000000..aa461c5 --- /dev/null +++ b/fog/libs/api-interfaces/src/lib/survivor.ts @@ -0,0 +1,18 @@ +export enum SurvivorState { + Active = 'Active', + Injured = 'Injured', + Sacrificed = 'Sacrificed' +} + +export interface SurvivorStats { + health: number; + stealth: number; + teamwork: number; + luck: number; +} + +export interface Survivor { + id: string; + state: SurvivorState; + stats: SurvivorStats; +} diff --git a/fog/libs/api-interfaces/tsconfig.lib.json b/fog/libs/api-interfaces/tsconfig.lib.json new file mode 100755 index 0000000..e6e0baf --- /dev/null +++ b/fog/libs/api-interfaces/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": [] + }, + "include": ["src/**/*.ts"] +} diff --git a/fog/libs/encounter-library/project.json b/fog/libs/encounter-library/project.json new file mode 100755 index 0000000..5413701 --- /dev/null +++ b/fog/libs/encounter-library/project.json @@ -0,0 +1,24 @@ +{ + "name": "encounter-library", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/encounter-library/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/encounter-library", + "main": "libs/encounter-library/src/index.ts", + "tsConfig": "libs/encounter-library/tsconfig.lib.json" + } + }, + "validate": { + "executor": "@nx/workspace:run-commands", + "options": { + "command": "ts-node libs/encounter-library/src/lib/validate-encounters.ts" + } + } + }, + "tags": ["scope:content"] +} diff --git a/fog/libs/encounter-library/src/index.ts b/fog/libs/encounter-library/src/index.ts new file mode 100755 index 0000000..020671a --- /dev/null +++ b/fog/libs/encounter-library/src/index.ts @@ -0,0 +1 @@ +export * from './lib/encounter-library'; diff --git a/fog/libs/encounter-library/src/lib/encounter-library.ts b/fog/libs/encounter-library/src/lib/encounter-library.ts new file mode 100755 index 0000000..87ae25b --- /dev/null +++ b/fog/libs/encounter-library/src/lib/encounter-library.ts @@ -0,0 +1,42 @@ +import encountersData from './encounters.json'; + +export interface EncounterRecord { + id: string; + tier: string; + baseSuccessChance: number; + difficultyTags: string[]; + perkTags: string[]; + flavor: string[]; +} + +interface EncounterDocument { + schemaVersion: number; + records: EncounterRecord[]; +} + +const doc = encountersData as EncounterDocument; + +export function listEncounters(): EncounterRecord[] { + return doc.records; +} + +export function getEncounterById(id: string): EncounterRecord | undefined { + return doc.records.find((record) => record.id === id); +} + +export function getRandomEncounterByTier(tier: string, seed = Date.now()): EncounterRecord { + const matching = doc.records.filter((record) => record.tier === tier); + const pool = matching.length > 0 ? matching : doc.records; + const index = Math.abs(seed) % pool.length; + return pool[index]; +} + +export function migrateEncounterDocument(document: EncounterDocument): EncounterDocument { + if (document.schemaVersion === 1) { + return document; + } + return { + schemaVersion: 1, + records: document.records + }; +} diff --git a/fog/libs/encounter-library/src/lib/encounter.schema.json b/fog/libs/encounter-library/src/lib/encounter.schema.json new file mode 100755 index 0000000..433f3be --- /dev/null +++ b/fog/libs/encounter-library/src/lib/encounter.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["schemaVersion", "records"], + "properties": { + "schemaVersion": { + "type": "integer", + "minimum": 1 + }, + "records": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "tier", + "baseSuccessChance", + "difficultyTags", + "perkTags", + "flavor" + ], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "tier": { "type": "string" }, + "baseSuccessChance": { "type": "number", "minimum": 0, "maximum": 1 }, + "difficultyTags": { + "type": "array", + "items": { "type": "string" } + }, + "perkTags": { + "type": "array", + "items": { "type": "string" } + }, + "flavor": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/fog/libs/encounter-library/src/lib/encounters.json b/fog/libs/encounter-library/src/lib/encounters.json new file mode 100755 index 0000000..3de57d9 --- /dev/null +++ b/fog/libs/encounter-library/src/lib/encounters.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "records": [ + { + "id": "cleanse_hex", + "tier": "common", + "baseSuccessChance": 0.62, + "difficultyTags": ["normal", "hard"], + "perkTags": ["totem", "support"], + "flavor": [ + "The bones glow with cursed light.", + "You cleanse the totem before the killer returns." + ] + }, + { + "id": "escape_hatch", + "tier": "rare", + "baseSuccessChance": 0.41, + "difficultyTags": ["hard", "nightmare"], + "perkTags": ["mobility", "luck"], + "flavor": [ + "A rusted hatch creaks in the distance.", + "One wrong step and the fog closes in." + ] + } + ] +} diff --git a/fog/libs/encounter-library/src/lib/validate-encounters.ts b/fog/libs/encounter-library/src/lib/validate-encounters.ts new file mode 100755 index 0000000..b077c82 --- /dev/null +++ b/fog/libs/encounter-library/src/lib/validate-encounters.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { z } from 'zod'; + +const encounterRecord = z.object({ + id: z.string().min(1), + tier: z.string(), + baseSuccessChance: z.number().min(0).max(1), + difficultyTags: z.array(z.string()), + perkTags: z.array(z.string()), + flavor: z.array(z.string()).min(1) +}); + +const encounterDocument = z.object({ + schemaVersion: z.number().int().min(1), + records: z.array(encounterRecord) +}); + +function main(): void { + const file = resolve(process.cwd(), 'libs/encounter-library/src/lib/encounters.json'); + const raw = readFileSync(file, 'utf-8'); + const parsed = JSON.parse(raw) as unknown; + encounterDocument.parse(parsed); + process.stdout.write('Encounter library validation passed.\n'); +} + +main(); diff --git a/fog/libs/encounter-library/tsconfig.lib.json b/fog/libs/encounter-library/tsconfig.lib.json new file mode 100755 index 0000000..e236ce0 --- /dev/null +++ b/fog/libs/encounter-library/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts", "src/**/*.json"] +} diff --git a/fog/libs/mission-logic/project.json b/fog/libs/mission-logic/project.json new file mode 100755 index 0000000..13a4a76 --- /dev/null +++ b/fog/libs/mission-logic/project.json @@ -0,0 +1,24 @@ +{ + "name": "mission-logic", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/mission-logic/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/mission-logic", + "main": "libs/mission-logic/src/index.ts", + "tsConfig": "libs/mission-logic/tsconfig.lib.json" + } + }, + "simulate": { + "executor": "@nx/workspace:run-commands", + "options": { + "command": "ts-node libs/mission-logic/src/simulator/cli.ts" + } + } + }, + "tags": ["scope:logic"] +} diff --git a/fog/libs/mission-logic/src/index.ts b/fog/libs/mission-logic/src/index.ts new file mode 100755 index 0000000..7dc6c93 --- /dev/null +++ b/fog/libs/mission-logic/src/index.ts @@ -0,0 +1,3 @@ +export * from './lib/encounter-resolver'; +export * from './lib/perk-math'; +export * from './lib/group-synergy.service'; diff --git a/fog/libs/mission-logic/src/lib/encounter-resolver.ts b/fog/libs/mission-logic/src/lib/encounter-resolver.ts new file mode 100755 index 0000000..9983cdb --- /dev/null +++ b/fog/libs/mission-logic/src/lib/encounter-resolver.ts @@ -0,0 +1,74 @@ +import { + EncounterResult, + MissionDifficulty, + MissionState, + ResolveEncounterInput, + SurvivorStats +} from '@fog-explorer/api-interfaces'; + +import { applyPerkModifiers, clamp } from './perk-math'; + +const DIFFICULTY_BASE: Record = { + [MissionDifficulty.Easy]: 0.72, + [MissionDifficulty.Normal]: 0.6, + [MissionDifficulty.Hard]: 0.48, + [MissionDifficulty.Nightmare]: 0.38 +}; + +export function resolveEncounter(input: ResolveEncounterInput): EncounterResult { + const baseChance = DIFFICULTY_BASE[input.difficulty]; + const perkBoost = input.perkIds.length * 0.015; + const statBoost = + input.stats.stealth * 0.002 + input.stats.teamwork * 0.002 + input.stats.luck * 0.002; + const successChance = clamp( + applyPerkModifiers(baseChance + perkBoost + statBoost, []), + 0.05, + 0.95 + ); + const roll = seededRoll(input.seed ?? input.tickIndex); + + if (roll <= successChance) { + return { + outcome: 'success', + text: 'The survivor outplayed the fog and advanced.', + successChance, + roll, + nextState: MissionState.InProgress, + nextStats: { ...input.stats, health: Math.min(100, input.stats.health + 1) } + }; + } + + const injuryThreshold = successChance + 0.25; + if (roll <= injuryThreshold) { + const injured = applyDamage(input.stats, 14); + return { + outcome: 'injury', + text: 'A close call leaves the survivor injured.', + successChance, + roll, + nextState: injured.health <= 0 ? MissionState.Failed : MissionState.InProgress, + nextStats: injured + }; + } + + return { + outcome: 'sacrifice', + text: 'The fog claims another soul.', + successChance, + roll, + nextState: MissionState.Failed, + nextStats: { ...input.stats, health: 0 } + }; +} + +function applyDamage(stats: SurvivorStats, amount: number): SurvivorStats { + return { + ...stats, + health: Math.max(0, stats.health - amount) + }; +} + +function seededRoll(seed: number): number { + const normalized = Math.abs(Math.sin(seed * 99991)) * 10000; + return normalized - Math.floor(normalized); +} diff --git a/fog/libs/mission-logic/src/lib/group-synergy.service.ts b/fog/libs/mission-logic/src/lib/group-synergy.service.ts new file mode 100755 index 0000000..8cf5632 --- /dev/null +++ b/fog/libs/mission-logic/src/lib/group-synergy.service.ts @@ -0,0 +1,15 @@ +import { PerkModifier } from '@fog-explorer/api-interfaces'; + +export interface GroupContext { + memberCount: number; + teamModifiers: PerkModifier[]; +} + +export class GroupSynergyService { + buildContext(teamModifiers: PerkModifier[]): GroupContext { + return { + memberCount: Math.max(1, Math.min(4, teamModifiers.length || 1)), + teamModifiers + }; + } +} diff --git a/fog/libs/mission-logic/src/lib/perk-math.ts b/fog/libs/mission-logic/src/lib/perk-math.ts new file mode 100755 index 0000000..195183d --- /dev/null +++ b/fog/libs/mission-logic/src/lib/perk-math.ts @@ -0,0 +1,18 @@ +import { ModifierKind, PerkModifier } from '@fog-explorer/api-interfaces'; + +export function applyPerkModifiers(baseChance: number, modifiers: PerkModifier[]): number { + let chance = baseChance; + for (const modifier of modifiers) { + if (modifier.kind === ModifierKind.Additive) { + chance += modifier.value; + } + if (modifier.kind === ModifierKind.Multiplicative) { + chance *= modifier.value; + } + } + return clamp(chance, 0.05, 0.95); +} + +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} diff --git a/fog/libs/mission-logic/src/simulator/cli.ts b/fog/libs/mission-logic/src/simulator/cli.ts new file mode 100755 index 0000000..4f22d68 --- /dev/null +++ b/fog/libs/mission-logic/src/simulator/cli.ts @@ -0,0 +1,67 @@ +import { MissionDifficulty, MissionState, SurvivorStats } from '@fog-explorer/api-interfaces'; + +import { resolveEncounter } from '../lib/encounter-resolver'; + +function parseArg(name: string, fallback: string): string { + const key = `--${name}=`; + const arg = process.argv.find((x) => x.startsWith(key)); + return arg ? arg.slice(key.length) : fallback; +} + +function main(): void { + const runs = Number(parseArg('runs', '10000')); + const seed = Number(parseArg('seed', '42')); + const difficulty = parseArg('difficulty', MissionDifficulty.Normal) as MissionDifficulty; + + let successes = 0; + let injuries = 0; + let sacrifices = 0; + const lengths: number[] = []; + + for (let run = 0; run < runs; run += 1) { + let state = MissionState.InProgress; + let tick = 0; + let stats: SurvivorStats = { health: 100, stealth: 10, teamwork: 10, luck: 10 }; + while (state === MissionState.InProgress && tick < 40) { + tick += 1; + const result = resolveEncounter({ + stats, + difficulty, + perkIds: [], + tickIndex: tick, + seed: seed + run * 101 + tick + }); + if (result.outcome === 'success') successes += 1; + if (result.outcome === 'injury') injuries += 1; + if (result.outcome === 'sacrifice') sacrifices += 1; + state = result.nextState; + stats = result.nextStats; + } + lengths.push(tick); + } + + lengths.sort((a, b) => a - b); + const p50 = lengths[Math.floor(lengths.length * 0.5)]; + const p90 = lengths[Math.floor(lengths.length * 0.9)]; + const p99 = lengths[Math.floor(lengths.length * 0.99)]; + + const totalEvents = successes + injuries + sacrifices; + const pct = (n: number) => ((n / totalEvents) * 100).toFixed(2); + + console.log( + JSON.stringify( + { + runs, + difficulty, + successRatePct: pct(successes), + injuryRatePct: pct(injuries), + sacrificeRatePct: pct(sacrifices), + missionLengthPercentiles: { p50, p90, p99 } + }, + null, + 2 + ) + ); +} + +main(); diff --git a/fog/libs/mission-logic/tsconfig.lib.json b/fog/libs/mission-logic/tsconfig.lib.json new file mode 100755 index 0000000..48d3bd5 --- /dev/null +++ b/fog/libs/mission-logic/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "rootDir": "../../", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/fog/nx.json b/fog/nx.json new file mode 100755 index 0000000..e8233ab --- /dev/null +++ b/fog/nx.json @@ -0,0 +1,31 @@ +{ + "$schema": "./node_modules/nx/schemas/nx-schema.json", + "namedInputs": { + "default": ["{projectRoot}/**/*", "sharedGlobals"], + "production": [ + "default", + "!{projectRoot}/**/*.spec.ts", + "!{projectRoot}/**/*.test.ts" + ], + "sharedGlobals": [] + }, + "plugins": [ + { + "plugin": "@nx/js/typescript", + "options": { + "typecheck": { + "targetName": "typecheck" + } + } + } + ], + "targetDefaults": { + "build": { + "dependsOn": ["^build"], + "inputs": ["production", "^production"] + }, + "test": { + "inputs": ["default", "^production"] + } + } +} diff --git a/fog/package.json b/fog/package.json new file mode 100755 index 0000000..404eb94 --- /dev/null +++ b/fog/package.json @@ -0,0 +1,50 @@ +{ + "name": "fog-expedition", + "version": "0.1.0", + "private": true, + "scripts": { + "nx": "nx", + "start": "nx serve twitch-extension-panel", + "start:api": "nx serve api", + "start:panel": "nx serve twitch-extension-panel", + "start:local-dev": "cross-env FOG_PANEL_MOCK=true nx serve twitch-extension-panel", + "build": "nx run-many -t build --all", + "test": "nx run-many -t test --all", + "simulate": "nx run mission-logic:simulate -- --runs=10000 --seed=42" + }, + "dependencies": { + "@angular/common": "^18.2.0", + "@angular/core": "^18.2.0", + "@angular/platform-browser": "^18.2.0", + "@angular/platform-browser-dynamic": "^18.2.0", + "@nestjs/common": "^10.4.2", + "@nestjs/config": "^3.2.3", + "@nestjs/core": "^10.4.2", + "@nestjs/jwt": "^10.2.0", + "@nestjs/platform-express": "^10.4.2", + "@nestjs/schedule": "^4.1.1", + "@prisma/client": "^5.20.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "ioredis": "^5.4.1", + "nestjs-pino": "^4.1.0", + "pino": "^9.4.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "zod": "^3.23.8" + }, + "devDependencies": { + "@nx/angular": "^20.0.0", + "@nx/jest": "^20.0.0", + "@nx/js": "^20.0.0", + "@nx/nest": "^20.0.0", + "@nx/node": "^20.0.0", + "@nx/workspace": "^20.0.0", + "@types/node": "^22.7.5", + "cross-env": "^7.0.3", + "nx": "^20.0.0", + "prisma": "^5.20.0", + "ts-node": "^10.9.2", + "typescript": "^5.6.2" + } +} diff --git a/fog/plan.md b/fog/plan.md new file mode 100755 index 0000000..0f72487 --- /dev/null +++ b/fog/plan.md @@ -0,0 +1,234 @@ +--- +name: fog-expedition-nx-and-mission-engine +overview: Set up an Nx-based monorepo for the Fog Expedition Twitch extension, implement a NestJS API with a tick-based mission engine, and integrate Twitch Extension auth, persistence, and content balance libraries. +todos: + - id: stage1-nx-foundation + content: Set up Nx workspace, add Angular/Nest plugins, generate Angular panel app, NestJS API app, and shared @fog-explorer/api-interfaces library, plus Docker Compose for Postgres and Redis. + status: completed + - id: stage2-extension-frontend + content: Build the Twitch panel Angular UI (panel shell, live log, survivor status) and integrate Twitch Extension auth plus an EBS HTTP client using shared interfaces. + status: completed + - id: stage3-mission-engine + content: Implement the 60-second tick engine, stateless encounter resolver, Redis-backed mission/lobby store, and SWF group logic services in the API. + status: completed + - id: stage4-ebs-persistence + content: Add NestJS controllers, Twitch JWT guard, Postgres models/migrations, and Twitch PubSub integration to replace chat commands with extension-driven actions. + status: completed + - id: stage5-content-balance + content: Create the JSON encounter library, formalize perk and modifier types, and add utilities/tests to tune and validate mission outcome probabilities. + status: completed +isProject: false +--- + +## Fog Expedition Nx Workspace & ZPG Engine Plan + +> **v2 note:** Additions introduced in this revision are marked **[NEW]** throughout. + +--- + +### Stage 1: Workspace & Infrastructure (Nx Foundation) + +- **Initialize Nx workspace & tooling** + - Ensure the root contains an Nx workspace with TypeScript support (`nx.json`, `project.json`/`workspace.json`, `tsconfig.base.json`, `package.json`). + - Add Nx plugins for Angular and NestJS (e.g. `@nx/angular`, `@nx/nest`, `@nx/node`). + - Configure `package.json` scripts for running Nx tasks (e.g. `"nx": "nx"`, `"start": "nx serve twitch-extension-panel"`). +- **Generate core apps** + - Create an Angular app for the Twitch Extension Panel at `[apps/twitch-extension-panel/src/main.ts]` via Nx generator (standalone components, no routing complexity initially). + - Create a NestJS API app at `[apps/api/src/main.ts]` using Nx's Nest generator, structured with modules for `Auth`, `Missions`, and `TickEngine`. +- **Shared API interfaces library** + - Generate a shared TypeScript library `@fog-explorer/api-interfaces` at `[libs/api-interfaces/src/index.ts]`. + - Define core domain types in small focused files: + - `[libs/api-interfaces/src/lib/survivor.ts]` containing `Survivor`, `SurvivorStats`, `SurvivorState` (`Active`, `Injured`, `Sacrificed`). + - `[libs/api-interfaces/src/lib/mission.ts]` containing `Mission`, `MissionState` (`Lobby`, `InProgress`, `Completed`, `Failed`), `EncounterResult`. + - `[libs/api-interfaces/src/lib/perk.ts]` containing `Perk`, `PerkModifier`, and `TeamPerk` flags for SWF mechanics. + - Re-export all public contracts from `[libs/api-interfaces/src/index.ts]` for clean imports in both `twitch-extension-panel` and `api`. +- **Docker & Docker Compose for infra** + - Add a root `docker-compose.yml` defining services: + - `postgres`: PostgreSQL with exposed port, DB name `fog_expedition`, volume, and healthcheck. + - `redis`: Redis for mission timers and lobbies. + - Optional `api` service wired to build from `[apps/api/Dockerfile]` and depend on `postgres` and `redis`. + - Create Dockerfiles: + - `[apps/api/Dockerfile]` building the NestJS API (install deps, build via Nx, run with `node dist/apps/api/main.js`). + - `[apps/twitch-extension-panel/Dockerfile]` for building and serving the Angular extension bundle for local testing. + - Define environment configuration via `.env`/`.env.local` for DB and Redis connection strings consumed by the API app. +- **[NEW] Local development without Twitch** + - Since `window.Twitch.ext` is unavailable in a local browser, a missing stub causes the panel to hang silently on startup. Solve this early to keep frontend development fluid. + - Create a `TwitchExtMock` module at `[apps/twitch-extension-panel/src/mocks/twitch-ext.mock.ts]` that: + - Exposes the same interface as `window.Twitch.ext` (`onAuthorized`, `onContext`, `onVisibilityChanged`, `listen`). + - Returns a hardcoded fake JWT and viewer ID so all downstream services initialise normally. + - Is swapped in via an `environment.ts` flag (`environment.mock = true`) — never shipped in production builds. + - Add a `local-dev` npm script that sets the mock flag and serves the panel against a local API. + +--- + +### Stage 2: The Extension Frontend (Angular Panel) + +- **Panel shell & layout** + - In `twitch-extension-panel`, create a `PanelShellComponent` (e.g. `[apps/twitch-extension-panel/src/app/panel-shell.component.ts]`) that: + - Hosts the Live Log area and Survivor Status area. + - Handles initial Twitch Extension context/bootstrap (mounts only after `window.Twitch.ext.onAuthorized`, or mock equivalent in dev). + - Add presentational components: + - `LiveLogComponent` for a scrolling log of encounters/events (Progress Quest style). + - `SurvivorStatusComponent` for current survivor(s), health/injury state, mission status, and perks. +- **Twitch EBS integration & auth** + - Implement a `TwitchAuthService` (e.g. `[apps/twitch-extension-panel/src/app/services/twitch-auth.service.ts]`) that: + - Wraps `window.Twitch.ext` calls (`onAuthorized`, `onContext`, `onVisibilityChanged`). + - Stores the latest JWT and parsed payload (obfuscated Twitch extension user ID). + - Implement an `EbsApiService` that: + - Attaches the Twitch JWT as `Authorization: Bearer ` on all HTTP calls to the NestJS API. + - Exposes methods like `startMission`, `getMissionState`, `getPerkInventory` using types from `@fog-explorer/api-interfaces`. +- **[NEW] EBS resilience & rate limiting** + - At scale, thousands of concurrent viewers can simultaneously poll `GET /missions/state`, overwhelming the API. + - Cache `GET /missions/state` responses in Redis with a 2–3 second TTL to absorb viewer polling spikes. + - The panel should degrade gracefully when the EBS is unreachable — display last-known state rather than a broken UI, and surface a subtle "reconnecting…" indicator. + - Add client-side exponential back-off with jitter for all EBS fetch retries. +- **State & real-time mission display** + - Create a `MissionStateStore` using Angular signals (or RxJS `BehaviorSubject` as a fallback) to hold: + - Current mission snapshot (`MissionState` + encounter history). + - Survivor state(s) and perk inventory. + - Wire the store to: + - Pull initial state from the API (`getMissionState`). + - Subscribe to Twitch PubSub events (via the Twitch JS helper) to apply incremental updates (new log lines, state transitions). + - Bind `PanelShellComponent`, `LiveLogComponent`, and `SurvivorStatusComponent` to this store using `computed` signals or `async` pipe for efficient change detection. +- **[NEW] PubSub message size guard** + - Twitch Extension PubSub enforces a 5 KB per-message limit. Unchecked log growth silently drops messages. + - Send only the latest N log lines per PubSub message (recommend N = 10–15). + - If the panel misses messages (detected via a sequence counter), trigger a full `getMissionState` re-fetch as a fallback. + +--- + +### Stage 3: The ZPG Mission Engine ("Corso") + +- **Global heartbeat & tick worker** + - In the NestJS API, add a `TickEngineModule` (e.g. `[apps/api/src/app/tick-engine/tick-engine.module.ts]`) and a `TickService` that: + - Uses `@nestjs/schedule` (cron) or a background worker to trigger a global tick every 60 seconds. + - Acquires a Redis-based distributed lock (e.g. `SETNX tick_lock`) to ensure only one instance runs the tick. + - Scans active missions from Redis and dispatches them to the encounter resolver. +- **[NEW] Tick engine resilience** + - Two failure modes need explicit answers before production: long ticks and process restarts. + - Set the Redis lock TTL to 50 seconds (10-second buffer before the next tick fires). If a tick is still running when the lock expires, emit a structured warning log — do not silently skip. + - Make encounter resolution idempotent: store a tick sequence number per mission in Redis and skip re-processing if the current tick index has already been resolved. This protects against double-application on restart mid-tick. + - Log tick start, duration, and number of missions processed as structured fields on every execution. +- **Encounter resolver (stateless core)** + - Implement a pure, stateless function in `[libs/api-interfaces/src/lib/encounter-resolver.ts]` or a new logic lib (e.g. `libs/mission-logic`): + - Signature like `resolveEncounter(survivor: SurvivorStats, perks: PerkModifier[], difficulty: Difficulty): EncounterResult`. + - Uses RNG with a pluggable seed source for deterministic tests. + - Applies modifiers as P{success} = Base + Σ Modifiers clamped to sane bounds. + - In the API, create an `EncounterService` that: + - Calls the resolver for each mission tick. + - Updates mission state (success/fail, injury, sacrifice) and logs events. +- **Group logic & SWF mechanics** + - Introduce a `GroupSynergyService` that: + - Accepts a group of 2–4 survivors and their perks. + - Aggregates "Team Perk" modifiers, applying them to all relevant rolls. + - Computes group-level outcomes (e.g., shared progress, split rewards, synchronized failure states). + - Extend the encounter resolver inputs to accept a `groupContext` that influences difficulty modifiers for group missions. +- **Redis for active missions & lobbies** + - Define Redis data structures in the API layer: + - `active_mission:{missionId}`: JSON or hash storing mission state, participant IDs, nextTickAt. + - `mission_lobby:{lobbyId}`: state for lobby members, mission template, and ready flags. + - Implement a `MissionStore` repository that abstracts Redis operations (get/set/expire/lists) from the core mission logic. + +--- + +### Stage 4: Twitch Extension Persistence (EBS) + +- **Extension action handlers instead of chat commands** + - In the NestJS `MissionsModule`, add controllers like `[apps/api/src/app/missions/missions.controller.ts]` exposing: + - `POST /missions/start` to replace `!explore` (triggered by the panel "Start Mission" button). + - `GET /missions/state` for current mission snapshot. + - `POST /missions/choose-perk` (future) or similar progression actions. + - Use DTOs shaped by `@fog-explorer/api-interfaces` types for request/response contracts. +- **Twitch JWT verification & identity mapping** + - Implement a `TwitchAuthModule` and `TwitchAuthGuard` that: + - Verifies incoming JWTs from the extension using the Twitch extension secret. + - Extracts the obfuscated Twitch user ID and extension channel ID, attaching them to the Nest request context. +- **[NEW] JWT secret rotation handling** + - Twitch can rotate the extension secret, causing all JWT verification to fail globally until the API is redeployed. + - On a JWT verification failure, the guard should attempt to re-fetch the current secret from the Twitch API before returning 401. + - Cache the fetched secret with a reasonable TTL (e.g. 5 minutes) so rotation recovery is near-instant. + - Emit a structured alert log whenever a secret re-fetch occurs — this is a high-signal operational event. +- **Postgres schema** + - Design Postgres tables (via migrations/TypeORM/Prisma) in the API project: + - `users` (internal ID, Twitch extension user ID, created_at). + - `survivors` (FK to `users`, stats, perk slots, current state: active/injured/sacrificed). Use soft-delete / `sacrificed_at` timestamp rather than hard deletion to support history and leaderboards. + - `missions` (FK to `survivors` or group, status, difficulty, timestamps). + - `mission_logs` (FK to `missions`, tick index, encounter key, rendered text, RNG details if needed). +- **[NEW] mission_logs retention strategy** + - `mission_logs` can grow unboundedly — a channel with high traffic can generate millions of rows within weeks. + - Add an `archived_at` nullable column to `mission_logs` from day one. + - Implement a lightweight nightly archival job that marks logs for completed/failed missions older than N days as archived (or bulk-moves them to an archive table). + - Add a Postgres index on `(mission_id, archived_at)` to keep active-mission log queries fast regardless of historical volume. +- **Twitch PubSub integration for ticks** + - Create a `TwitchPubSubService` in the API that: + - Signs messages to Twitch's Extension PubSub endpoint using the extension client ID and secret. + - Publishes log updates and mission state deltas on each tick for relevant channels. + - On the frontend, extend `TwitchAuthService` or a dedicated `PubSubService` to: + - Subscribe to mission update topics. + - Forward received events into the `MissionStateStore`, appending log lines and updating survivor/mission state. +- **[NEW] Broadcaster configuration panel** + - Broadcasters frequently need to configure extension behaviour (difficulty presets, max survivors, opt-in features). This is often needed earlier than expected. + - Create a separate Angular app (or a route within the panel) for the Twitch Broadcaster Config view. + - Add a `POST /channel/config` EBS endpoint that accepts channel-level settings and stores them against the channel ID in Postgres. + - The config schema should at minimum cover: mission difficulty preset, max party size, and feature flags for beta mechanics. + - Apply these settings in `MissionsModule` when resolving encounters for that channel. + +--- + +### Stage 5: Content Library & Balance + +- **JSON encounter library** + - Create a dedicated library `@fog-explorer/encounter-library` at `[libs/encounter-library/src/lib/encounters.json]` to hold all flavor text and base parameters. + - Structure encounters as JSON records: + - Keys like `"cleanse_hex"`, `"escape_hatch"`, etc. + - Fields for base success chance, difficulty tags, flavor text variants, and perk tags. + - Add a small TypeScript wrapper in `[libs/encounter-library/src/lib/encounter-library.ts]` to: + - Load encounters. + - Expose helper functions (e.g. `getEncounterById`, `getRandomEncounterByTier`). +- **[NEW] Encounter JSON schema versioning** + - Adding new fields to `encounters.json` silently breaks records that predate those fields, causing runtime surprises. + - Define a JSON Schema (or Zod schema) for the encounter record format and commit it alongside `encounters.json`. + - Add a build-time validation step (Nx target) that validates all encounter records against the schema on every build. + - Increment a `schemaVersion` field whenever breaking fields are added, and provide a migration helper for existing records. +- **Perk system & balance utilities** + - In `@fog-explorer/api-interfaces` (or a dedicated `@fog-explorer/perks` lib), formalize perk modifier types: + - Additive and multiplicative modifiers, flat reroll mechanics, group-boosting perks. + - Implement a `PerkMath` helper that: + - Aggregates modifiers into a final P{success} value. + - Separates survivor-level modifiers from team-level modifiers to keep SWF behaviour clear. + - Provide a small test harness (unit tests in the mission logic lib) to: + - Validate that typical perk loadouts produce reasonable success/fail/injury rates. + - Guard against accidental balance regressions. +- **[NEW] Simulation CLI for balance tuning** + - Unit tests confirm correctness but are too coarse for tuning probability distributions. A headless simulator is far more useful. + - Add an Nx target: `nx run mission-logic:simulate -- --runs=10000 --perkSet=flashlight,spine_chill` + - The simulator should output: mean success rate, injury rate, sacrifice rate, and a percentile breakdown of mission lengths. + - Wire it into CI with a fixed seed so regressions in outcome distributions surface as test failures without flakiness. + +--- + +### [NEW] Cross-Cutting Concerns + +These topics were absent from the original plan but span all stages and should be established early. + +- **Observability** + - Install Pino as the NestJS logger (structured JSON output). Replace all `console.log` usage with pino log calls. + - Define a standard log schema with fields: `traceId`, `channelId`, `missionId`, `tickIndex`, `durationMs`, `event`. + - Instrument the tick engine specifically: log tick start, active mission count, tick duration, and any per-mission errors as structured fields on every execution. + - Add basic application metrics (tick processing time, encounter outcomes, error rates) — even a simple in-memory counter exported to a `/metrics` endpoint is sufficient to start. + +--- + +### High-Level Architecture Diagram + +```mermaid +flowchart LR + viewerPanel["Twitch Panel (Angular)"] -->|"JWT via window.Twitch.ext"| apiGateway["NestJS API (EBS)"] + apiGateway -->|"REST: /missions/*"| missionEngine["Mission Engine / Tick Service"] + missionEngine -->|"state + logs"| postgresDb["Postgres"] + missionEngine -->|"timers + lobbies"| redisStore["Redis"] + missionEngine -->|"Tick updates"| twitchPubSub["Twitch Extension PubSub"] + twitchPubSub -->|"Push events"| viewerPanel + apiGateway -->|"channel config"| postgresDb + broadcasterConfig["Broadcaster Config Panel"] -->|"POST /channel/config"| apiGateway +``` \ No newline at end of file diff --git a/fog/tsconfig.base.json b/fog/tsconfig.base.json new file mode 100755 index 0000000..56da096 --- /dev/null +++ b/fog/tsconfig.base.json @@ -0,0 +1,25 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "rootDir": ".", + "sourceMap": true, + "declaration": false, + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "importHelpers": false, + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM"], + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@fog-explorer/api-interfaces": ["libs/api-interfaces/src/index.ts"], + "@fog-explorer/encounter-library": [ + "libs/encounter-library/src/index.ts" + ], + "@fog-explorer/mission-logic": ["libs/mission-logic/src/index.ts"] + } + }, + "exclude": ["node_modules", "tmp"] +} diff --git a/fog/tsconfig.json b/fog/tsconfig.json new file mode 100755 index 0000000..bfacec1 --- /dev/null +++ b/fog/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.base.json", + "files": [], + "references": [] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..70c7272 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "fog", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}