first commit
This commit is contained in:
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"]
|
||||
}
|
||||
Reference in New Issue
Block a user