first commit
This commit is contained in:
33
fog/.devcontainer/devcontainer.json
Executable file
33
fog/.devcontainer/devcontainer.json
Executable file
@@ -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'"
|
||||||
|
}
|
||||||
13
fog/.devcontainer/docker-compose.devcontainer.yml
Executable file
13
fog/.devcontainer/docker-compose.devcontainer.yml
Executable file
@@ -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
|
||||||
6
fog/.env
Executable file
6
fog/.env
Executable file
@@ -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
|
||||||
6
fog/.env.example
Executable file
6
fog/.env.example
Executable file
@@ -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
|
||||||
36
fog/README.md
Executable file
36
fog/README.md
Executable file
@@ -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`
|
||||||
10
fog/apps/api/Dockerfile
Executable file
10
fog/apps/api/Dockerfile
Executable 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"]
|
||||||
52
fog/apps/api/prisma/schema.prisma
Executable file
52
fog/apps/api/prisma/schema.prisma
Executable 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
25
fog/apps/api/project.json
Executable 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"]
|
||||||
|
}
|
||||||
31
fog/apps/api/src/app/app.module.ts
Executable file
31
fog/apps/api/src/app/app.module.ts
Executable 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 {}
|
||||||
17
fog/apps/api/src/app/channel-config/channel-config.controller.ts
Executable file
17
fog/apps/api/src/app/channel-config/channel-config.controller.ts
Executable 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
fog/apps/api/src/app/channel-config/channel-config.module.ts
Executable file
11
fog/apps/api/src/app/channel-config/channel-config.module.ts
Executable 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 {}
|
||||||
23
fog/apps/api/src/app/channel-config/channel-config.service.ts
Executable file
23
fog/apps/api/src/app/channel-config/channel-config.service.ts
Executable 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
fog/apps/api/src/app/metrics/metrics.controller.ts
Executable file
13
fog/apps/api/src/app/metrics/metrics.controller.ts
Executable 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
fog/apps/api/src/app/metrics/metrics.module.ts
Executable file
11
fog/apps/api/src/app/metrics/metrics.module.ts
Executable 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 {}
|
||||||
14
fog/apps/api/src/app/metrics/metrics.service.ts
Executable file
14
fog/apps/api/src/app/metrics/metrics.service.ts
Executable 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
36
fog/apps/api/src/app/missions/dto.ts
Executable file
36
fog/apps/api/src/app/missions/dto.ts
Executable 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;
|
||||||
|
}
|
||||||
18
fog/apps/api/src/app/missions/mission-log-archival.job.ts
Executable file
18
fog/apps/api/src/app/missions/mission-log-archival.job.ts
Executable 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.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
62
fog/apps/api/src/app/missions/mission.store.ts
Executable file
62
fog/apps/api/src/app/missions/mission.store.ts
Executable 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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
fog/apps/api/src/app/missions/missions.controller.ts
Executable file
31
fog/apps/api/src/app/missions/missions.controller.ts
Executable 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
fog/apps/api/src/app/missions/missions.module.ts
Executable file
16
fog/apps/api/src/app/missions/missions.module.ts
Executable 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 {}
|
||||||
107
fog/apps/api/src/app/missions/missions.service.ts
Executable file
107
fog/apps/api/src/app/missions/missions.service.ts
Executable 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
fog/apps/api/src/app/tick-engine/tick-engine.module.ts
Executable file
10
fog/apps/api/src/app/tick-engine/tick-engine.module.ts
Executable 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 {}
|
||||||
63
fog/apps/api/src/app/tick-engine/tick.service.ts
Executable file
63
fog/apps/api/src/app/tick-engine/tick.service.ts
Executable 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
fog/apps/api/src/app/twitch-auth/twitch-auth.guard.ts
Executable file
51
fog/apps/api/src/app/twitch-auth/twitch-auth.guard.ts
Executable 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
fog/apps/api/src/app/twitch-auth/twitch-auth.module.ts
Executable file
12
fog/apps/api/src/app/twitch-auth/twitch-auth.module.ts
Executable 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 {}
|
||||||
39
fog/apps/api/src/app/twitch-auth/twitch-secret.service.ts
Executable file
39
fog/apps/api/src/app/twitch-auth/twitch-secret.service.ts
Executable 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
9
fog/apps/api/src/app/twitch-pubsub/twitch-pubsub.module.ts
Executable file
9
fog/apps/api/src/app/twitch-pubsub/twitch-pubsub.module.ts
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { TwitchPubSubService } from './twitch-pubsub.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [TwitchPubSubService],
|
||||||
|
exports: [TwitchPubSubService]
|
||||||
|
})
|
||||||
|
export class TwitchPubSubModule {}
|
||||||
25
fog/apps/api/src/app/twitch-pubsub/twitch-pubsub.service.ts
Executable file
25
fog/apps/api/src/app/twitch-pubsub/twitch-pubsub.service.ts
Executable 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
fog/apps/api/src/assets/.gitkeep
Executable file
1
fog/apps/api/src/assets/.gitkeep
Executable file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
23
fog/apps/api/src/main.ts
Executable file
23
fog/apps/api/src/main.ts
Executable 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
9
fog/apps/api/tsconfig.app.json
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
26
fog/apps/broadcaster-config/project.json
Executable file
26
fog/apps/broadcaster-config/project.json
Executable file
@@ -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"]
|
||||||
|
}
|
||||||
11
fog/apps/broadcaster-config/src/index.html
Executable file
11
fog/apps/broadcaster-config/src/index.html
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Fog Broadcaster Config</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<fog-broadcaster-config></fog-broadcaster-config>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
45
fog/apps/broadcaster-config/src/main.ts
Executable file
45
fog/apps/broadcaster-config/src/main.ts
Executable file
@@ -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: `
|
||||||
|
<h2>Fog Broadcaster Config</h2>
|
||||||
|
<label>Difficulty</label>
|
||||||
|
<select [(ngModel)]="difficulty">
|
||||||
|
<option *ngFor="let d of difficulties" [value]="d">{{ d }}</option>
|
||||||
|
</select>
|
||||||
|
<label>Max party size</label>
|
||||||
|
<input type="number" [(ngModel)]="maxPartySize" min="1" max="4" />
|
||||||
|
<button (click)="save()">Save</button>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
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));
|
||||||
8
fog/apps/broadcaster-config/tsconfig.app.json
Executable file
8
fog/apps/broadcaster-config/tsconfig.app.json
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
12
fog/apps/twitch-extension-panel/Dockerfile
Executable file
12
fog/apps/twitch-extension-panel/Dockerfile
Executable file
@@ -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
|
||||||
26
fog/apps/twitch-extension-panel/project.json
Executable file
26
fog/apps/twitch-extension-panel/project.json
Executable file
@@ -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"]
|
||||||
|
}
|
||||||
28
fog/apps/twitch-extension-panel/src/app/live-log.component.ts
Executable file
28
fog/apps/twitch-extension-panel/src/app/live-log.component.ts
Executable file
@@ -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: `
|
||||||
|
<section class="panel-card">
|
||||||
|
<h3>Live Log</h3>
|
||||||
|
<div class="log-list">
|
||||||
|
<p *ngFor="let line of lines">#{{ line.sequence }} - {{ line.text }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
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[] = [];
|
||||||
|
}
|
||||||
66
fog/apps/twitch-extension-panel/src/app/panel-shell.component.ts
Executable file
66
fog/apps/twitch-extension-panel/src/app/panel-shell.component.ts
Executable file
@@ -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: `
|
||||||
|
<main class="layout">
|
||||||
|
<header>
|
||||||
|
<h2>Fog Expedition</h2>
|
||||||
|
<p *ngIf="reconnecting()">reconnecting...</p>
|
||||||
|
</header>
|
||||||
|
<button (click)="startMission()">Start Mission</button>
|
||||||
|
<fog-survivor-status [mission]="mission()"></fog-survivor-status>
|
||||||
|
<fog-live-log [lines]="logLines()"></fog-live-log>
|
||||||
|
</main>
|
||||||
|
`,
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
45
fog/apps/twitch-extension-panel/src/app/services/ebs-api.service.ts
Executable file
45
fog/apps/twitch-extension-panel/src/app/services/ebs-api.service.ts
Executable file
@@ -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<MissionSnapshot> {
|
||||||
|
return this.fetchWithRetry(`/missions/state?missionId=${missionId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchWithRetry<T>(path: string, init: RequestInit, attempt = 0): Promise<T> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
fog/apps/twitch-extension-panel/src/app/services/mission-state.store.ts
Executable file
19
fog/apps/twitch-extension-panel/src/app/services/mission-state.store.ts
Executable file
@@ -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<MissionSnapshot | null>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
fog/apps/twitch-extension-panel/src/app/services/pubsub.service.ts
Executable file
43
fog/apps/twitch-extension-panel/src/app/services/pubsub.service.ts
Executable file
@@ -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>): 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
47
fog/apps/twitch-extension-panel/src/app/services/twitch-auth.service.ts
Executable file
47
fog/apps/twitch-extension-panel/src/app/services/twitch-auth.service.ts
Executable file
@@ -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<TwitchAuthState | null>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
fog/apps/twitch-extension-panel/src/app/survivor-status.component.ts
Executable file
35
fog/apps/twitch-extension-panel/src/app/survivor-status.component.ts
Executable file
@@ -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: `
|
||||||
|
<section class="panel-card">
|
||||||
|
<h3>Survivor Status</h3>
|
||||||
|
<ng-container *ngIf="mission; else idle">
|
||||||
|
<p>Mission: {{ mission.id }}</p>
|
||||||
|
<p>State: {{ mission.state }}</p>
|
||||||
|
<p>Health: {{ mission.stats.health }}</p>
|
||||||
|
<p>Stealth: {{ mission.stats.stealth }}</p>
|
||||||
|
<p>Teamwork: {{ mission.stats.teamwork }}</p>
|
||||||
|
<p>Luck: {{ mission.stats.luck }}</p>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #idle>
|
||||||
|
<p>No active mission.</p>
|
||||||
|
</ng-template>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
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;
|
||||||
|
}
|
||||||
5
fog/apps/twitch-extension-panel/src/environments/environment.prod.ts
Executable file
5
fog/apps/twitch-extension-panel/src/environments/environment.prod.ts
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
mock: false,
|
||||||
|
apiBaseUrl: '/api'
|
||||||
|
};
|
||||||
5
fog/apps/twitch-extension-panel/src/environments/environment.ts
Executable file
5
fog/apps/twitch-extension-panel/src/environments/environment.ts
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
mock: true,
|
||||||
|
apiBaseUrl: 'http://localhost:3333'
|
||||||
|
};
|
||||||
11
fog/apps/twitch-extension-panel/src/index.html
Executable file
11
fog/apps/twitch-extension-panel/src/index.html
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Fog Expedition Panel</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<fog-panel-shell></fog-panel-shell>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7
fog/apps/twitch-extension-panel/src/main.ts
Executable file
7
fog/apps/twitch-extension-panel/src/main.ts
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
import { PanelShellComponent } from './app/panel-shell.component';
|
||||||
|
|
||||||
|
bootstrapApplication(PanelShellComponent).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
25
fog/apps/twitch-extension-panel/src/mocks/twitch-ext.mock.ts
Executable file
25
fog/apps/twitch-extension-panel/src/mocks/twitch-ext.mock.ts
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
export interface TwitchExtLike {
|
||||||
|
onAuthorized(callback: (auth: { token: string; userId: string }) => void): void;
|
||||||
|
onContext(callback: (context: Record<string, unknown>) => 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.
|
||||||
|
}
|
||||||
|
};
|
||||||
10
fog/apps/twitch-extension-panel/src/styles.css
Executable file
10
fog/apps/twitch-extension-panel/src/styles.css
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: Inter, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #0f1117;
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
8
fog/apps/twitch-extension-panel/tsconfig.app.json
Executable file
8
fog/apps/twitch-extension-panel/tsconfig.app.json
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
47
fog/docker-compose.yml
Executable file
47
fog/docker-compose.yml
Executable file
@@ -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:
|
||||||
18
fog/libs/api-interfaces/project.json
Executable file
18
fog/libs/api-interfaces/project.json
Executable file
@@ -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"]
|
||||||
|
}
|
||||||
5
fog/libs/api-interfaces/src/index.ts
Executable file
5
fog/libs/api-interfaces/src/index.ts
Executable file
@@ -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';
|
||||||
8
fog/libs/api-interfaces/src/lib/channel-config.ts
Executable file
8
fog/libs/api-interfaces/src/lib/channel-config.ts
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
import { MissionDifficulty } from './mission';
|
||||||
|
|
||||||
|
export interface ChannelConfig {
|
||||||
|
channelId: string;
|
||||||
|
difficultyPreset: MissionDifficulty;
|
||||||
|
maxPartySize: number;
|
||||||
|
featureFlags: Record<string, boolean>;
|
||||||
|
}
|
||||||
19
fog/libs/api-interfaces/src/lib/encounter.ts
Executable file
19
fog/libs/api-interfaces/src/lib/encounter.ts
Executable file
@@ -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;
|
||||||
|
}
|
||||||
35
fog/libs/api-interfaces/src/lib/mission.ts
Executable file
35
fog/libs/api-interfaces/src/lib/mission.ts
Executable file
@@ -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[];
|
||||||
|
}
|
||||||
19
fog/libs/api-interfaces/src/lib/perk.ts
Executable file
19
fog/libs/api-interfaces/src/lib/perk.ts
Executable file
@@ -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[];
|
||||||
|
}
|
||||||
18
fog/libs/api-interfaces/src/lib/survivor.ts
Executable file
18
fog/libs/api-interfaces/src/lib/survivor.ts
Executable file
@@ -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;
|
||||||
|
}
|
||||||
9
fog/libs/api-interfaces/tsconfig.lib.json
Executable file
9
fog/libs/api-interfaces/tsconfig.lib.json
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"declaration": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
24
fog/libs/encounter-library/project.json
Executable file
24
fog/libs/encounter-library/project.json
Executable file
@@ -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"]
|
||||||
|
}
|
||||||
1
fog/libs/encounter-library/src/index.ts
Executable file
1
fog/libs/encounter-library/src/index.ts
Executable file
@@ -0,0 +1 @@
|
|||||||
|
export * from './lib/encounter-library';
|
||||||
42
fog/libs/encounter-library/src/lib/encounter-library.ts
Executable file
42
fog/libs/encounter-library/src/lib/encounter-library.ts
Executable file
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
45
fog/libs/encounter-library/src/lib/encounter.schema.json
Executable file
45
fog/libs/encounter-library/src/lib/encounter.schema.json
Executable file
@@ -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
|
||||||
|
}
|
||||||
27
fog/libs/encounter-library/src/lib/encounters.json
Executable file
27
fog/libs/encounter-library/src/lib/encounters.json
Executable file
@@ -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."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
27
fog/libs/encounter-library/src/lib/validate-encounters.ts
Executable file
27
fog/libs/encounter-library/src/lib/validate-encounters.ts
Executable file
@@ -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();
|
||||||
9
fog/libs/encounter-library/tsconfig.lib.json
Executable file
9
fog/libs/encounter-library/tsconfig.lib.json
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"declaration": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.json"]
|
||||||
|
}
|
||||||
24
fog/libs/mission-logic/project.json
Executable file
24
fog/libs/mission-logic/project.json
Executable file
@@ -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"]
|
||||||
|
}
|
||||||
3
fog/libs/mission-logic/src/index.ts
Executable file
3
fog/libs/mission-logic/src/index.ts
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './lib/encounter-resolver';
|
||||||
|
export * from './lib/perk-math';
|
||||||
|
export * from './lib/group-synergy.service';
|
||||||
74
fog/libs/mission-logic/src/lib/encounter-resolver.ts
Executable file
74
fog/libs/mission-logic/src/lib/encounter-resolver.ts
Executable file
@@ -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, number> = {
|
||||||
|
[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);
|
||||||
|
}
|
||||||
15
fog/libs/mission-logic/src/lib/group-synergy.service.ts
Executable file
15
fog/libs/mission-logic/src/lib/group-synergy.service.ts
Executable file
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
18
fog/libs/mission-logic/src/lib/perk-math.ts
Executable file
18
fog/libs/mission-logic/src/lib/perk-math.ts
Executable file
@@ -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));
|
||||||
|
}
|
||||||
67
fog/libs/mission-logic/src/simulator/cli.ts
Executable file
67
fog/libs/mission-logic/src/simulator/cli.ts
Executable file
@@ -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();
|
||||||
10
fog/libs/mission-logic/tsconfig.lib.json
Executable file
10
fog/libs/mission-logic/tsconfig.lib.json
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"rootDir": "../../",
|
||||||
|
"declaration": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
31
fog/nx.json
Executable file
31
fog/nx.json
Executable file
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
fog/package.json
Executable file
50
fog/package.json
Executable file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
234
fog/plan.md
Executable file
234
fog/plan.md
Executable file
@@ -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 <token>` 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
|
||||||
|
```
|
||||||
25
fog/tsconfig.base.json
Executable file
25
fog/tsconfig.base.json
Executable file
@@ -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"]
|
||||||
|
}
|
||||||
5
fog/tsconfig.json
Executable file
5
fog/tsconfig.json
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.base.json",
|
||||||
|
"files": [],
|
||||||
|
"references": []
|
||||||
|
}
|
||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "fog",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user