first commit

This commit is contained in:
Hussar
2026-04-12 15:35:50 +00:00
commit 42d20cb0ed
80 changed files with 2210 additions and 0 deletions

10
fog/apps/api/Dockerfile Executable file
View File

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

View File

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

25
fog/apps/api/project.json Executable file
View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, ChannelConfig>();
async saveForChannel(channelId: string, config: ChannelConfig): Promise<ChannelConfig> {
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<ChannelConfig | null> {
return this.inMemoryConfigs.get(channelId) ?? null;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class MetricsService {
private readonly counters = new Map<string, number>();
increment(name: string, by = 1): void {
this.counters.set(name, (this.counters.get(name) ?? 0) + by);
}
snapshot(): Record<string, number> {
return Object.fromEntries(this.counters.entries());
}
}

View File

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

View File

@@ -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<void> {
// 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.'
});
}
}

View File

@@ -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<MissionSnapshot | null> {
const raw = await this.redis.get(this.missionKey(missionId));
return raw ? (JSON.parse(raw) as MissionSnapshot) : null;
}
async setMission(mission: MissionSnapshot): Promise<void> {
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<string[]> {
return this.redis.smembers('active_missions');
}
async getCachedMissionState(cacheKey: string): Promise<MissionSnapshot | null> {
const raw = await this.redis.get(cacheKey);
return raw ? (JSON.parse(raw) as MissionSnapshot) : null;
}
async cacheMissionState(
cacheKey: string,
mission: MissionSnapshot,
ttlSeconds = 3
): Promise<void> {
await this.redis.set(cacheKey, JSON.stringify(mission), 'EX', ttlSeconds);
}
async getMissionTickSequence(missionId: string): Promise<number> {
const raw = await this.redis.get(this.tickSeqKey(missionId));
return Number(raw ?? 0);
}
async setMissionTickSequence(missionId: string, index: number): Promise<void> {
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`;
}
}

View File

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

View File

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

View File

@@ -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<MissionSnapshot> {
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<MissionSnapshot> {
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<MissionSnapshot | null> {
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 };
}
}

View File

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

View File

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

View File

@@ -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<boolean> {
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<Record<string, unknown>> {
const firstSecret = await this.secretService.getCurrentSecret(false);
try {
return this.jwt.verify(token, { secret: firstSecret }) as Record<string, unknown>;
} catch {
const refreshedSecret = await this.secretService.getCurrentSecret(true);
try {
return this.jwt.verify(token, {
secret: refreshedSecret
}) as Record<string, unknown>;
} catch {
throw new UnauthorizedException('Invalid Twitch extension JWT');
}
}
}
}

View File

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

View File

@@ -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<string> {
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<string> {
// 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<string>('TWITCH_EXTENSION_SECRET', 'replace-me');
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TwitchPubSubService } from './twitch-pubsub.service';
@Module({
providers: [TwitchPubSubService],
exports: [TwitchPubSubService]
})
export class TwitchPubSubModule {}

View File

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

View File

@@ -0,0 +1 @@

23
fog/apps/api/src/main.ts Executable file
View File

@@ -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<void> {
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();

9
fog/apps/api/tsconfig.app.json Executable file
View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "CommonJS",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}