Refactor API and enhance Angular integration

- Removed `ciTargetName` from `nx.json`.
- Updated `package.json` to include new dependencies: `@types/seedrandom`, `fast-check`, `happy-dom`, and `@nestjs/schedule`.
- Modified `pnpm-lock.yaml` to reflect the addition of new packages and their versions.
- Improved project documentation in `PROJECT_CONTEXT.md` to clarify the use of Zod schemas and Angular framework decisions.
- Introduced new Angular components and patterns in the `.agents/skills/frontend-angular` directory, including examples and reference materials for Angular 21+ features.
This commit is contained in:
Maurycy
2026-05-07 14:25:46 +00:00
parent 65af268b86
commit e8523d270e
66 changed files with 4074 additions and 72 deletions

View File

@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ScheduleModule } from '@nestjs/schedule';
import { MissionsModule } from './missions/missions.module';
import { TickEngineModule } from './tick-engine/tick-engine.module';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
imports: [
ScheduleModule.forRoot(),
MissionsModule,
TickEngineModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,72 @@
import {
CanActivate,
createParamDecorator,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { createHmac } from 'crypto';
export interface TwitchJwtPayload {
opaque_user_id: string;
channel_id: string;
role: string;
exp: number;
}
interface HttpRequest {
headers: Record<string, string | string[] | undefined>;
twitchClaims?: TwitchJwtPayload;
}
export const TwitchClaims = createParamDecorator(
(_: unknown, ctx: ExecutionContext): TwitchJwtPayload => {
const req = ctx.switchToHttp().getRequest<HttpRequest>();
if (!req.twitchClaims) throw new UnauthorizedException('Missing claims');
return req.twitchClaims;
}
);
@Injectable()
export class TwitchJwtGuard implements CanActivate {
canActivate(ctx: ExecutionContext): boolean {
const req = ctx.switchToHttp().getRequest<HttpRequest>();
const authHeader = req.headers['authorization'];
const auth = Array.isArray(authHeader) ? authHeader[0] : authHeader;
if (!auth?.startsWith('Bearer ')) throw new UnauthorizedException();
const token = auth.slice(7);
req.twitchClaims = verifyAndDecode(token);
return true;
}
}
function verifyAndDecode(token: string): TwitchJwtPayload {
const parts = token.split('.');
if (parts.length !== 3) throw new UnauthorizedException('Malformed JWT');
const [header, payloadB64, sig] = parts;
const secret = process.env['TWITCH_EXTENSION_SECRET'];
if (!secret) throw new UnauthorizedException('No extension secret configured');
// Twitch shared secret is base64-encoded; decode to raw bytes for HMAC.
const secretBytes = Buffer.from(secret, 'base64');
const expected = createHmac('sha256', secretBytes)
.update(`${header}.${payloadB64}`)
.digest('base64url');
if (expected !== sig) throw new UnauthorizedException('Invalid signature');
const raw = Buffer.from(
payloadB64.replace(/-/g, '+').replace(/_/g, '/'),
'base64'
).toString('utf8');
const payload = JSON.parse(raw) as TwitchJwtPayload;
if (payload.exp < Math.floor(Date.now() / 1000)) {
throw new UnauthorizedException('Token expired');
}
return payload;
}

View File

@@ -0,0 +1,171 @@
import { Injectable, Logger } from '@nestjs/common';
import type {
EncounterResult,
Mission,
MissionParticipant,
MissionStateResponse,
} from '@fog-explorer/api-interfaces';
import {
getEncounterById,
getLibraryVersion,
getRandomEncounterByTier,
pickFlavor,
} from '@fog-explorer/encounter-library';
import { resolveEncounter } from '@fog-explorer/mission-logic';
import seedrandom = require('seedrandom');
import { GroupSynergyService } from './group-synergy.service';
const TICK_JITTER_MS = 5_000;
const TICK_BASE_INTERVAL_MS = 60_000;
const RECENT_LOG_MAX = 20;
@Injectable()
export class EncounterService {
private readonly logger = new Logger(EncounterService.name);
constructor(private readonly groupSynergy: GroupSynergyService) {}
/**
* Resolves one tick for the given mission state.
* Returns the updated state, or null if the mission ended.
*/
processTick(
current: NonNullable<MissionStateResponse>
): NonNullable<MissionStateResponse> {
const { mission, survivors } = current;
const tickIndex = mission.tickIndex + 1;
const seed = buildSeed(mission.id, tickIndex);
const rng = seedrandom(seed);
const tier = mission.difficulty as 1 | 2 | 3;
const encounter =
mission.encounterLibraryVersion === getLibraryVersion()
? getRandomEncounterByTier(tier, rng)
: getRandomEncounterByTier(tier, rng);
const groupModifiers = this.groupSynergy.collectGroupModifiers(survivors);
const newLog: EncounterResult[] = [];
let updatedSurvivors = survivors.map((s) => ({ ...s }));
let updatedParticipants = mission.participants.map((p) => ({ ...p }));
for (const participant of mission.participants) {
if (participant.state === 'sacrificed') continue;
const survivor = survivors.find((s) => s.id === participant.survivorId);
if (!survivor) continue;
const augmentedPerks = this.groupSynergy.buildAugmentedPerks(
survivor,
groupModifiers
);
const result = resolveEncounter({
seed: `${seed}:${survivor.id}`,
missionId: mission.id,
tickIndex,
difficulty: mission.difficulty,
encounter,
survivor: {
id: survivor.id,
state: survivor.state,
stats: survivor.stats,
perkSlots: augmentedPerks,
hookCount: participant.hookCount,
},
});
const libEncounter = getEncounterById(encounter.key);
const flavor = libEncounter
? pickFlavor(libEncounter, { success: result.success }, rng)
: result.logText;
newLog.push({ ...result, logText: flavor });
this.logger.log({
message: 'tick resolved',
missionId: mission.id,
channelId: 'unknown',
tickIndex,
survivorId: survivor.id,
encounterKey: encounter.key,
success: result.success,
seed: result.seed,
});
const stateChange = result.survivorStateChange;
if (stateChange) {
updatedSurvivors = updatedSurvivors.map((s) =>
s.id === survivor.id ? { ...s, state: stateChange.to } : s
);
updatedParticipants = updatedParticipants.map((p) => {
if (p.survivorId !== survivor.id) return p;
const hookCount =
stateChange.to === 'downed'
? Math.min(2, p.hookCount + 1)
: p.hookCount;
return { ...p, state: stateChange.to, hookCount };
});
}
}
const updatedMission = buildUpdatedMission(
mission,
updatedParticipants,
tickIndex
);
const recentLog = [
...newLog,
...current.recentLog,
].slice(0, RECENT_LOG_MAX);
return {
mission: updatedMission,
survivors: updatedSurvivors,
recentLog,
};
}
}
function buildSeed(missionId: string, tickIndex: number): string {
return `${missionId}:${tickIndex}`;
}
function buildUpdatedMission(
mission: Mission,
participants: MissionParticipant[],
tickIndex: number
): Mission {
const allSacrificed = participants.every(
(p) => p.state === 'sacrificed'
);
const allEscaped = participants.every(
(p) => p.state === 'active' || p.state === 'idle'
);
let status = mission.status;
let endedAt = mission.endedAt;
if (allSacrificed && mission.status === 'active') {
status = 'sacrifice';
endedAt = new Date().toISOString();
} else if (allEscaped && tickIndex >= 10 && mission.status === 'active') {
// Success after at least 10 ticks with all survivors still active
status = 'success';
endedAt = new Date().toISOString();
}
const jitter = Math.floor(Math.random() * TICK_JITTER_MS);
const nextTickAt = new Date(
Date.now() + TICK_BASE_INTERVAL_MS + jitter
).toISOString();
return {
...mission,
status,
endedAt,
participants,
tickIndex,
nextTickAt,
};
}

View File

@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import type { Perk, PerkModifier, Survivor } from '@fog-explorer/api-interfaces';
export interface SynergyModifier extends PerkModifier {
sourceKey: string;
}
@Injectable()
export class GroupSynergyService {
/**
* Collect every perk modifier from all active survivors in a SWF group,
* deduplicating by perkKey so one survivor's perk doesn't stack with itself
* if they appear multiple times.
*/
collectGroupModifiers(survivors: Survivor[]): SynergyModifier[] {
const seen = new Set<string>();
const modifiers: SynergyModifier[] = [];
for (const survivor of survivors) {
if (survivor.state === 'sacrificed') continue;
for (const perk of survivor.perkSlots) {
const dedupKey = `${survivor.id}:${perk.key}`;
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
for (const mod of perk.modifiers) {
modifiers.push({ ...mod, sourceKey: perk.key });
}
}
}
return modifiers;
}
/**
* Build an augmented perk list for a specific survivor that includes
* group-wide synergy perks from other survivors as phantom perk entries.
* Only `successChance` modifiers propagate group-wide; stat modifiers stay personal.
*/
buildAugmentedPerks(
targetSurvivor: Survivor,
groupModifiers: SynergyModifier[]
): Perk[] {
const personal = targetSurvivor.perkSlots;
const personalKeys = new Set(personal.map((p) => p.key));
const synergies = groupModifiers
.filter((m) => m.target === 'successChance' && !personalKeys.has(m.sourceKey))
.map(
(m): Perk => ({
id: '00000000-0000-4000-a000-000000000000',
key: `synergy:${m.sourceKey}`,
name: `Group: ${m.sourceKey}`,
description: '',
tags: [],
modifiers: [{ target: m.target, type: m.type, amount: m.amount, condition: m.condition }],
})
);
return [...personal, ...synergies];
}
}

View File

@@ -0,0 +1,87 @@
import { Inject, Injectable } from '@nestjs/common';
import { Redis } from 'ioredis';
import type { MissionStateResponse } from '@fog-explorer/api-interfaces';
import { REDIS_CLIENT } from '../redis/redis.module';
const ACTIVE_MISSION_PREFIX = 'active_mission:';
const CHANNEL_MISSION_KEY = (channelId: string) => `channel_mission:${channelId}`;
const TICK_QUEUE_KEY = 'missions:tick_queue';
const LOCK_PREFIX = 'tick_lock:';
const LOCK_TTL_MS = 30_000;
@Injectable()
export class MissionStoreService {
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {}
async getActiveMission(missionId: string): Promise<MissionStateResponse | null> {
const raw = await this.redis.get(`${ACTIVE_MISSION_PREFIX}${missionId}`);
if (!raw) return null;
return JSON.parse(raw) as MissionStateResponse;
}
async setActiveMission(state: NonNullable<MissionStateResponse>): Promise<void> {
const missionId = state.mission.id;
const key = `${ACTIVE_MISSION_PREFIX}${missionId}`;
await this.redis.set(key, JSON.stringify(state));
}
async deleteActiveMission(missionId: string): Promise<void> {
await this.redis.del(`${ACTIVE_MISSION_PREFIX}${missionId}`);
}
async getChannelMissionId(channelId: string): Promise<string | null> {
return this.redis.get(CHANNEL_MISSION_KEY(channelId));
}
async setChannelMissionId(channelId: string, missionId: string): Promise<void> {
await this.redis.set(CHANNEL_MISSION_KEY(channelId), missionId);
}
async clearChannelMission(channelId: string): Promise<void> {
await this.redis.del(CHANNEL_MISSION_KEY(channelId));
}
async getStateForChannel(channelId: string): Promise<MissionStateResponse> {
const missionId = await this.getChannelMissionId(channelId);
if (!missionId) return null;
return this.getActiveMission(missionId);
}
// Sorted-set tick queue — score is the nextTickAt Unix ms timestamp.
async scheduleTick(missionId: string, nextTickAtMs: number): Promise<void> {
await this.redis.zadd(TICK_QUEUE_KEY, nextTickAtMs, missionId);
}
async removeMissionFromQueue(missionId: string): Promise<void> {
await this.redis.zrem(TICK_QUEUE_KEY, missionId);
}
async getDueMissionIds(nowMs: number): Promise<string[]> {
return this.redis.zrangebyscore(TICK_QUEUE_KEY, '-inf', nowMs);
}
// Distributed lock — SET NX PX with a unique token.
async acquireLock(missionId: string): Promise<string | null> {
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
const result = await this.redis.set(
`${LOCK_PREFIX}${missionId}`,
token,
'PX',
LOCK_TTL_MS,
'NX'
);
return result === 'OK' ? token : null;
}
async releaseLock(missionId: string, token: string): Promise<void> {
// Atomic check-and-delete via Lua to avoid releasing another owner's lock.
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await this.redis.eval(script, 1, `${LOCK_PREFIX}${missionId}`, token);
}
}

View File

@@ -0,0 +1,48 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import {
MissionStateResponse,
MissionStateResponseSchema,
StartMissionRequestSchema,
} from '@fog-explorer/api-interfaces';
import { TwitchClaims, TwitchJwtGuard, TwitchJwtPayload } from '../auth/twitch-jwt.guard';
import { MissionStoreService } from './mission-store.service';
import { MissionsService } from './missions.service';
@Controller('missions')
@UseGuards(TwitchJwtGuard)
export class MissionsController {
constructor(
private readonly store: MissionStoreService,
private readonly missions: MissionsService
) {}
@Get('state')
async getState(
@TwitchClaims() claims: TwitchJwtPayload
): Promise<MissionStateResponse> {
const state = await this.store.getStateForChannel(claims.channel_id);
return MissionStateResponseSchema.parse(state);
}
@Post('start')
@HttpCode(HttpStatus.CREATED)
async startMission(
@TwitchClaims() claims: TwitchJwtPayload,
@Body() body: unknown
): Promise<MissionStateResponse> {
if (!claims.opaque_user_id.startsWith('U')) {
throw new NotFoundException('Anonymous viewers cannot start missions');
}
const { difficulty } = StartMissionRequestSchema.parse(body);
return this.missions.startMission(claims, difficulty);
}
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { RedisModule } from '../redis/redis.module';
import { EncounterService } from './encounter.service';
import { GroupSynergyService } from './group-synergy.service';
import { MissionStoreService } from './mission-store.service';
import { MissionsController } from './missions.controller';
import { MissionsService } from './missions.service';
@Module({
imports: [RedisModule],
controllers: [MissionsController],
providers: [
MissionStoreService,
MissionsService,
EncounterService,
GroupSynergyService,
],
exports: [MissionStoreService, EncounterService],
})
export class MissionsModule {}

View File

@@ -0,0 +1,75 @@
import { Injectable } from '@nestjs/common';
import type {
Mission,
MissionStateResponse,
Survivor,
SurvivorStats,
} from '@fog-explorer/api-interfaces';
import { getLibraryVersion } from '@fog-explorer/encounter-library';
import { TwitchJwtPayload } from '../auth/twitch-jwt.guard';
import { MissionStoreService } from './mission-store.service';
const TICK_BASE_INTERVAL_MS = 60_000;
const TICK_JITTER_MS = 5_000;
@Injectable()
export class MissionsService {
constructor(private readonly store: MissionStoreService) {}
async startMission(
claims: TwitchJwtPayload,
difficulty: number
): Promise<NonNullable<MissionStateResponse>> {
const missionId = crypto.randomUUID();
const survivorId = crypto.randomUUID();
const now = new Date().toISOString();
const jitter = Math.floor(Math.random() * TICK_JITTER_MS);
const nextTickAt = new Date(Date.now() + TICK_BASE_INTERVAL_MS + jitter).toISOString();
const stats: SurvivorStats = defaultStats();
const survivor: Survivor = {
id: survivorId,
opaqueUserId: claims.opaque_user_id,
channelId: claims.channel_id,
name: defaultName(claims.opaque_user_id),
state: 'active',
stats,
perkSlots: [],
createdAt: now,
};
const mission: Mission = {
id: missionId,
groupId: null,
participants: [{ survivorId, state: 'active', hookCount: 0 }],
difficulty,
status: 'active',
encounterLibraryVersion: getLibraryVersion(),
nextTickAt,
tickIndex: 0,
startedAt: now,
endedAt: null,
};
const state: NonNullable<MissionStateResponse> = {
mission,
survivors: [survivor],
recentLog: [],
};
await this.store.setActiveMission(state);
await this.store.setChannelMissionId(claims.channel_id, missionId);
await this.store.scheduleTick(missionId, new Date(nextTickAt).getTime());
return state;
}
}
function defaultStats(): SurvivorStats {
return { objectives: 5, survival: 5, altruism: 5 };
}
function defaultName(opaqueUserId: string): string {
return `Survivor ${opaqueUserId.slice(-4)}`;
}

View File

@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { Redis } from 'ioredis';
export const REDIS_CLIENT = Symbol('REDIS_CLIENT');
@Module({
providers: [
{
provide: REDIS_CLIENT,
useFactory: () =>
new Redis({
host: process.env['REDIS_HOST'] ?? 'localhost',
port: Number(process.env['REDIS_PORT'] ?? 6379),
lazyConnect: true,
}),
},
],
exports: [REDIS_CLIENT],
})
export class RedisModule {}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { MissionsModule } from '../missions/missions.module';
import { TickService } from './tick.service';
@Module({
imports: [MissionsModule],
providers: [TickService],
})
export class TickEngineModule {}

View File

@@ -0,0 +1,61 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { MissionStoreService } from '../missions/mission-store.service';
import { EncounterService } from '../missions/encounter.service';
@Injectable()
export class TickService {
private readonly logger = new Logger(TickService.name);
constructor(
private readonly store: MissionStoreService,
private readonly encounters: EncounterService
) {}
@Cron(CronExpression.EVERY_10_SECONDS)
async heartbeat(): Promise<void> {
const now = Date.now();
const dueMissionIds = await this.store.getDueMissionIds(now);
await Promise.allSettled(
dueMissionIds.map((id) => this.processMission(id))
);
}
private async processMission(missionId: string): Promise<void> {
const token = await this.store.acquireLock(missionId);
if (!token) return; // Another worker has the lock
try {
const state = await this.store.getActiveMission(missionId);
if (!state || state.mission.status !== 'active') {
await this.store.removeMissionFromQueue(missionId);
return;
}
const updated = this.encounters.processTick(state);
await this.store.setActiveMission(updated);
if (
updated.mission.status === 'active' ||
updated.mission.status === 'lobby'
) {
const nextMs = new Date(updated.mission.nextTickAt).getTime();
await this.store.scheduleTick(missionId, nextMs);
} else {
await this.store.removeMissionFromQueue(missionId);
this.logger.log({
message: 'mission ended',
missionId,
status: updated.mission.status,
tickIndex: updated.mission.tickIndex,
});
}
} catch (err) {
this.logger.error({ message: 'tick failed', missionId, err });
} finally {
await this.store.releaseLock(missionId, token);
}
}
}

View File

@@ -12,7 +12,7 @@ async function bootstrap() {
const globalPrefix = 'api';
app.setGlobalPrefix(globalPrefix);
const port = process.env.PORT || 3000;
await app.listen(port);
await app.listen(port, '0.0.0.0');
Logger.log(
`🚀 Application is running on: http://localhost:${port}/${globalPrefix}`,
);

View File

@@ -62,8 +62,10 @@
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {}
"executor": "@nx/vitest:test",
"options": {
"configFile": "apps/overlay/vitest.config.mts"
}
},
"serve-static": {
"continuous": true,

View File

@@ -2,9 +2,12 @@ import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './ebs/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners(), provideRouter(appRoutes)],
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(withInterceptors([authInterceptor])),
],
};

View File

@@ -1,20 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
import { NxWelcome } from './nx-welcome';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App, NxWelcome],
}).compileComponents();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain(
'Welcome overlay',
);
});
});

View File

@@ -1,13 +1,9 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
import { NxWelcome } from './nx-welcome';
import { PanelShellComponent } from './panel/panel-shell.component';
@Component({
imports: [NxWelcome, RouterModule],
imports: [PanelShellComponent],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
template: `<app-panel-shell />`,
})
export class App {
protected title = 'overlay';
}
export class App {}

View File

@@ -0,0 +1,68 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { TwitchAuthService } from '../twitch/twitch-auth.service';
import { authInterceptor } from './auth.interceptor';
import { EbsApiService, EBS_BASE_URL } from './ebs-api.service';
function makeJwt(): string {
const payload = btoa(JSON.stringify({ opaque_user_id: 'U123', channel_id: 'ch1', role: 'viewer', exp: 9999999999 }))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
return `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`;
}
describe('authInterceptor', () => {
let controller: HttpTestingController;
let authService: TwitchAuthService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: EBS_BASE_URL, useValue: 'https://test.local' },
],
});
controller = TestBed.inject(HttpTestingController);
authService = TestBed.inject(TwitchAuthService);
});
afterEach(() => controller.verify());
it('omits Authorization header when not yet authorized', () => {
const svc = TestBed.inject(EbsApiService);
svc.getMissionState().subscribe();
const req = controller.expectOne('https://test.local/missions/state');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush(null);
});
it('attaches Bearer token after onAuthorized fires', () => {
const jwt = makeJwt();
(window as Window & { Twitch?: unknown }).Twitch = {
ext: {
onAuthorized: (cb: (a: TwitchAuth) => void) =>
cb({ channelId: 'ch1', clientId: 'c1', token: jwt, userId: 'U123' }),
onContext: () => undefined,
onVisibilityChanged: () => undefined,
listen: () => undefined,
unlisten: () => undefined,
send: () => undefined,
},
};
authService.init();
const svc = TestBed.inject(EbsApiService);
svc.getMissionState().subscribe();
const req = controller.expectOne('https://test.local/missions/state');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${jwt}`);
req.flush(null);
delete (window as Window & { Twitch?: unknown }).Twitch;
});
});

View File

@@ -0,0 +1,9 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { TwitchAuthService } from '../twitch/twitch-auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(TwitchAuthService).auth()?.token;
if (!token) return next(req);
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
};

View File

@@ -0,0 +1,78 @@
import { provideHttpClient } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { EbsApiService, EBS_BASE_URL } from './ebs-api.service';
const BASE = 'https://test.local';
describe('EbsApiService', () => {
let service: EbsApiService;
let controller: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: EBS_BASE_URL, useValue: BASE },
],
});
service = TestBed.inject(EbsApiService);
controller = TestBed.inject(HttpTestingController);
});
afterEach(() => controller.verify());
describe('getMissionState', () => {
it('GETs /missions/state and returns null for no active mission', () => {
let result: unknown;
service.getMissionState().subscribe((v) => (result = v));
controller.expectOne(`${BASE}/missions/state`).flush(null);
expect(result).toBeNull();
});
it('throws ZodError when server returns invalid shape', () => {
let error: unknown;
service.getMissionState().subscribe({ error: (e) => (error = e) });
controller.expectOne(`${BASE}/missions/state`).flush({ bad: 'data' });
expect(error).toBeDefined();
});
});
describe('startMission', () => {
it('POSTs to /missions/start with difficulty', () => {
service.startMission({ difficulty: 2 }).subscribe({ error: () => undefined });
const req = controller.expectOne(`${BASE}/missions/start`);
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ difficulty: 2 });
req.flush({ bad: 'shape' });
});
it('throws ZodError for invalid difficulty before sending request', () => {
expect(() => service.startMission({ difficulty: 99 })).toThrow();
controller.expectNone(`${BASE}/missions/start`);
});
});
describe('choosePerk', () => {
it('POSTs to /missions/choose-perk with perkKey', () => {
service.choosePerk({ perkKey: 'iron_will' }).subscribe({ error: () => undefined });
const req = controller.expectOne(`${BASE}/missions/choose-perk`);
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ perkKey: 'iron_will' });
req.flush({ bad: 'shape' });
});
it('throws ZodError for empty perkKey before sending request', () => {
expect(() => service.choosePerk({ perkKey: '' })).toThrow();
controller.expectNone(`${BASE}/missions/choose-perk`);
});
});
});

View File

@@ -0,0 +1,41 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable, InjectionToken } from '@angular/core';
import {
ChoosePerkRequest,
ChoosePerkRequestSchema,
MissionStateResponse,
MissionStateResponseSchema,
MissionSchema,
StartMissionRequest,
StartMissionRequestSchema,
SurvivorSchema,
} from '@fog-explorer/api-interfaces';
import { map, Observable } from 'rxjs';
export const EBS_BASE_URL = new InjectionToken<string>('EBS_BASE_URL');
@Injectable({ providedIn: 'root' })
export class EbsApiService {
private readonly http = inject(HttpClient);
private readonly baseUrl = inject(EBS_BASE_URL, { optional: true }) ?? 'https://localhost:3000';
getMissionState(): Observable<MissionStateResponse> {
return this.http
.get(`${this.baseUrl}/missions/state`)
.pipe(map((body) => MissionStateResponseSchema.parse(body)));
}
startMission(req: StartMissionRequest) {
StartMissionRequestSchema.parse(req);
return this.http
.post(`${this.baseUrl}/missions/start`, req)
.pipe(map((body) => MissionSchema.parse(body)));
}
choosePerk(req: ChoosePerkRequest) {
ChoosePerkRequestSchema.parse(req);
return this.http
.post(`${this.baseUrl}/missions/choose-perk`, req)
.pipe(map((body) => SurvivorSchema.parse(body)));
}
}

View File

@@ -0,0 +1,159 @@
import { provideHttpClient } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { EBS_BASE_URL } from '../ebs/ebs-api.service';
import { TwitchAuthService } from '../twitch/twitch-auth.service';
import { MissionStateStore } from './mission-state.store';
const BASE = 'https://test.local';
const STATE_URL = `${BASE}/missions/state`;
function makeJwt(): string {
const payload = btoa(
JSON.stringify({ opaque_user_id: 'U123', channel_id: 'ch1', role: 'viewer', exp: 9999999999 })
).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
return `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`;
}
describe('MissionStateStore', () => {
let store: MissionStateStore;
let authService: TwitchAuthService;
let controller: HttpTestingController;
let visibilityCallback: ((visible: boolean, ctx: TwitchContext) => void) | undefined;
let pubsubCallbacks: Record<string, (t: string, ct: string, msg: string) => void>;
function mountTwitchExt(alreadyAuthorized = true) {
const jwt = makeJwt();
pubsubCallbacks = {};
(window as Window & { Twitch?: unknown }).Twitch = {
ext: {
onAuthorized: (cb: (a: TwitchAuth) => void) => {
if (alreadyAuthorized) {
cb({ channelId: 'ch1', clientId: 'c1', token: jwt, userId: 'U123' });
}
},
onContext: () => undefined,
onVisibilityChanged: (cb: (v: boolean, ctx: TwitchContext) => void) => {
visibilityCallback = cb;
},
listen: (target: string, cb: (t: string, ct: string, msg: string) => void) => {
pubsubCallbacks[target] = cb;
},
unlisten: () => undefined,
send: () => undefined,
},
};
}
function removeTwitchExt() {
delete (window as Window & { Twitch?: unknown }).Twitch;
}
beforeEach(() => {
visibilityCallback = undefined;
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: EBS_BASE_URL, useValue: BASE },
],
});
store = TestBed.inject(MissionStateStore);
authService = TestBed.inject(TwitchAuthService);
controller = TestBed.inject(HttpTestingController);
});
afterEach(() => {
controller.verify();
removeTwitchExt();
});
describe('init', () => {
it('does not fetch state when not yet authorized', () => {
mountTwitchExt(false);
authService.init();
store.init();
controller.expectNone(STATE_URL);
});
it('fetches state immediately when authorized', () => {
mountTwitchExt();
authService.init();
store.init();
controller.expectOne(STATE_URL).flush(null);
expect(store.state()).toBeNull();
});
it('sets loading true during fetch and false after', () => {
mountTwitchExt();
authService.init();
store.init();
expect(store.loading()).toBe(true);
controller.expectOne(STATE_URL).flush(null);
expect(store.loading()).toBe(false);
});
it('sets error signal when request fails', () => {
mountTwitchExt();
authService.init();
store.init();
controller.expectOne(STATE_URL).flush('server error', {
status: 500,
statusText: 'Internal Server Error',
});
expect(store.error()).toBeTruthy();
expect(store.loading()).toBe(false);
});
});
describe('PubSub', () => {
it('re-fetches when a broadcast arrives while visible', () => {
mountTwitchExt();
authService.init();
store.init();
controller.expectOne(STATE_URL).flush(null);
pubsubCallbacks['broadcast']('broadcast', 'json', '{}');
controller.expectOne(STATE_URL).flush(null);
});
it('ignores broadcast when overlay is hidden', () => {
mountTwitchExt();
authService.init();
store.init();
controller.expectOne(STATE_URL).flush(null);
visibilityCallback!(false, {} as TwitchContext);
pubsubCallbacks['broadcast']('broadcast', 'json', '{}');
controller.expectNone(STATE_URL);
});
});
describe('refresh', () => {
it('re-fetches state when called', () => {
mountTwitchExt();
authService.init();
store.init();
controller.expectOne(STATE_URL).flush(null);
store.refresh();
controller.expectOne(STATE_URL).flush(null);
});
it('is a no-op when not yet authorized', () => {
mountTwitchExt(false);
authService.init();
store.refresh();
controller.expectNone(STATE_URL);
});
});
});

View File

@@ -0,0 +1,58 @@
import { inject, Injectable, DestroyRef, signal } from '@angular/core';
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
import { EbsApiService } from '../ebs/ebs-api.service';
import { TwitchAuthService } from '../twitch/twitch-auth.service';
@Injectable({ providedIn: 'root' })
export class MissionStateStore {
private readonly ebs = inject(EbsApiService);
private readonly authService = inject(TwitchAuthService);
private readonly destroyRef = inject(DestroyRef);
private readonly _state = signal<MissionStateResponse>(null);
private readonly _loading = signal(false);
private readonly _error = signal<unknown>(null);
readonly state = this._state.asReadonly();
readonly loading = this._loading.asReadonly();
readonly error = this._error.asReadonly();
init(): void {
this.fetchState();
this.subscribePubSub();
}
/** Call when the overlay becomes visible after being hidden to reconcile missed ticks. */
refresh(): void {
this.fetchState();
}
private fetchState(): void {
if (!this.authService.auth()) return;
this._loading.set(true);
this.ebs.getMissionState().subscribe({
next: (state) => {
this._state.set(state);
this._loading.set(false);
this._error.set(null);
},
error: (err) => {
this._loading.set(false);
this._error.set(err);
},
});
}
private subscribePubSub(): void {
if (!window.Twitch?.ext) return;
const listener = (_target: string, _contentType: string, _message: string): void => {
if (!this.authService.isVisible()) return;
this.fetchState();
};
window.Twitch.ext.listen('broadcast', listener);
this.destroyRef.onDestroy(() => window.Twitch?.ext.unlisten('broadcast', listener));
}
}

View File

@@ -0,0 +1,52 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
export interface AmbientEventData {
type: 'injury' | 'sacrifice' | 'mission-complete' | 'perk-acquired';
description: string;
}
@Component({
selector: 'app-ambient-event',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [`
:host { display: block; }
.panel {
width: 290px;
height: 92px;
background: rgba(15, 18, 22, 0.88);
padding: 12px 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
cursor: pointer;
}
.event-type {
font-family: sans-serif;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6a7080;
}
.event-type.injury, .event-type.downed { color: #B8842E; }
.event-type.sacrifice { color: #C03A3A; }
.event-type.mission-complete { color: #E8A547; }
.event-desc {
font-family: 'JetBrains Mono', 'IBM Plex Mono', monospace;
font-size: 13px;
color: #c8ccd0;
}
`],
template: `
<div class="panel" (click)="dismissed.emit()">
<span class="event-type" [class]="event().type">{{ event().type }}</span>
<span class="event-desc">{{ event().description }}</span>
</div>
`,
})
export class AmbientEventComponent {
event = input.required<AmbientEventData>();
dismissed = output<void>();
}

View File

@@ -0,0 +1,129 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
@Component({
selector: 'app-expanded-panel',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [`
:host { display: block; }
.panel {
width: 320px;
height: 440px;
background: rgba(15, 18, 22, 0.95);
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: 16px;
gap: 12px;
}
.close-btn {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
color: #6a7080;
font-size: 18px;
cursor: pointer;
line-height: 1;
}
.survivor-name {
font-family: 'Cormorant', serif;
font-size: 20px;
font-weight: 700;
color: #e0e4e8;
letter-spacing: 0.02em;
}
.survivor-state {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #E8A547;
}
.survivor-state.injured, .survivor-state.downed { color: #B8842E; }
.survivor-state.sacrificed { color: #C03A3A; }
.mission-strip {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
border-top: 1px solid rgba(255,255,255,0.06);
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.difficulty { color: #E8A547; letter-spacing: 0.1em; font-size: 12px; }
.tick { font-family: monospace; font-size: 11px; color: #6a7080; margin-left: auto; }
.log {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 4px;
}
.log-entry {
font-family: 'JetBrains Mono', 'IBM Plex Mono', monospace;
font-size: 11px;
color: #c8ccd0;
line-height: 1.5;
}
.perk-slots {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.perk {
font-size: 10px;
background: rgba(232, 165, 71, 0.12);
color: #E8A547;
padding: 2px 6px;
border-radius: 2px;
}
.perk-empty { font-size: 11px; color: #6a7080; font-style: italic; }
`],
template: `
<div class="panel" style="position:relative">
<button class="close-btn" (click)="close.emit()">✕</button>
@if (survivor(); as s) {
<div>
<div class="survivor-name">{{ s.name }}</div>
<div class="survivor-state" [class]="s.state">{{ s.state }}</div>
<div class="perk-slots">
@for (perk of s.perkSlots; track perk.id) {
<span class="perk">{{ perk.name }}</span>
} @empty {
<span class="perk-empty">No perks equipped</span>
}
</div>
</div>
}
@if (mission(); as m) {
<div class="mission-strip">
<span class="difficulty">{{ difficultyGlyphs() }}</span>
<span class="tick">T+{{ m.tickIndex }}</span>
</div>
}
<div class="log">
@for (entry of recentLog(); track entry.tickIndex) {
<div class="log-entry">{{ entry.logText }}</div>
}
</div>
</div>
`,
})
export class ExpandedPanelComponent {
missionState = input.required<NonNullable<MissionStateResponse>>();
close = output<void>();
protected mission = computed(() => this.missionState().mission);
protected survivor = computed(() => this.missionState().survivors[0] ?? null);
protected recentLog = computed(() => [...this.missionState().recentLog].reverse());
protected difficultyGlyphs = computed(() =>
'◆'.repeat(this.missionState().mission.difficulty)
);
}

View File

@@ -0,0 +1,78 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
@Component({
selector: 'app-minimised-panel',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [`
:host { display: block; }
.panel {
display: flex;
align-items: center;
gap: 8px;
width: 290px;
height: 56px;
background: rgba(15, 18, 22, 0.88);
padding: 0 12px;
box-sizing: border-box;
}
.lantern {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 50%;
background: #1a1e24;
border: 3px solid #E8A547;
cursor: pointer;
box-sizing: border-box;
}
.lantern.injured { border-color: #B8842E; }
.lantern.downed, .lantern.sacrificed { border-color: #C03A3A; }
.ticker {
flex: 1;
overflow: hidden;
}
.log-line {
display: block;
font-family: 'JetBrains Mono', 'IBM Plex Mono', monospace;
font-size: 11px;
color: #c8ccd0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.idle { color: #6a7080; font-style: italic; }
`],
template: `
<div class="panel">
<div class="lantern" [class]="survivorState()" (click)="lanternClick.emit()"></div>
<div class="ticker">
@if (latestLogLine(); as line) {
<span class="log-line">{{ line }}</span>
} @else {
<span class="log-line idle">The fog stirs…</span>
}
</div>
</div>
`,
})
export class MinimisedPanelComponent {
missionState = input.required<NonNullable<MissionStateResponse>>();
lanternClick = output<void>();
protected survivorState = computed(
() => this.missionState().survivors[0]?.state ?? 'idle'
);
protected latestLogLine = computed(() => {
const log = this.missionState().recentLog;
return log.length ? log[log.length - 1].logText : null;
});
}

View File

@@ -0,0 +1,253 @@
import { provideHttpClient } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { By } from '@angular/platform-browser';
import { TestBed } from '@angular/core/testing';
import { MissionSchema, MissionStateResponse, SurvivorSchema } from '@fog-explorer/api-interfaces';
import { EBS_BASE_URL } from '../ebs/ebs-api.service';
import { TwitchAuthService } from '../twitch/twitch-auth.service';
import { AmbientEventData } from './ambient-event.component';
import { PanelShellComponent } from './panel-shell.component';
// Stub child components using @Input()/@Output() — JIT doesn't recognise signal inputs
@Component({ selector: 'app-minimised-panel', standalone: true, template: '' })
class MinimisedPanelStub {
@Input() missionState!: NonNullable<MissionStateResponse>;
@Output() lanternClick = new EventEmitter<void>();
}
@Component({ selector: 'app-ambient-event', standalone: true, template: '' })
class AmbientEventStub {
@Input() event!: AmbientEventData;
@Output() dismissed = new EventEmitter<void>();
}
@Component({ selector: 'app-expanded-panel', standalone: true, template: '' })
class ExpandedPanelStub {
@Input() missionState!: NonNullable<MissionStateResponse>;
@Output() close = new EventEmitter<void>();
}
const BASE = 'https://test.local';
const STATE_URL = `${BASE}/missions/state`;
function makeJwt(): string {
const payload = btoa(
JSON.stringify({ opaque_user_id: 'U123', channel_id: 'ch1', role: 'viewer', exp: 9999999999 })
).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
return `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`;
}
function makeAnonymousJwt(): string {
const payload = btoa(
JSON.stringify({ opaque_user_id: 'Aanon', channel_id: 'ch1', role: 'viewer', exp: 9999999999 })
).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
return `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`;
}
const SURVIVOR = SurvivorSchema.parse({
id: 'a1b2c3d4-e5f6-4789-a012-b3c4d5e6f701',
opaqueUserId: 'U123',
channelId: 'ch1',
name: 'Hana',
state: 'active',
stats: { objectives: 5, survival: 5, altruism: 5 },
perkSlots: [],
createdAt: new Date().toISOString(),
});
const MISSION = MissionSchema.parse({
id: 'b2c3d4e5-f6a7-4890-b123-c4d5e6f7a801',
groupId: null,
participants: [{ survivorId: SURVIVOR.id, state: 'active', hookCount: 0 }],
difficulty: 2,
status: 'active',
encounterLibraryVersion: '1.0.0',
nextTickAt: new Date(Date.now() + 60000).toISOString(),
tickIndex: 3,
startedAt: new Date().toISOString(),
endedAt: null,
});
const MISSION_STATE = { mission: MISSION, survivors: [SURVIVOR], recentLog: [] };
@Component({
standalone: true,
imports: [PanelShellComponent],
template: `<app-panel-shell />`,
})
class HostComponent {}
describe('PanelShellComponent', () => {
let controller: HttpTestingController;
let authService: TwitchAuthService;
let visibilityCallback: ((v: boolean, ctx: TwitchContext) => void) | undefined;
function mountTwitchExt(jwt = makeJwt()) {
(window as Window & { Twitch?: unknown }).Twitch = {
ext: {
onAuthorized: (cb: (a: TwitchAuth) => void) =>
cb({ channelId: 'ch1', clientId: 'c1', token: jwt, userId: 'U123' }),
onContext: () => undefined,
onVisibilityChanged: (cb: (v: boolean, ctx: TwitchContext) => void) => {
visibilityCallback = cb;
},
listen: () => undefined,
unlisten: () => undefined,
send: () => undefined,
},
};
}
function createComponent() {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
return fixture;
}
beforeEach(() => {
visibilityCallback = undefined;
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: EBS_BASE_URL, useValue: BASE },
],
});
TestBed.overrideComponent(PanelShellComponent, {
set: {
imports: [MinimisedPanelStub, AmbientEventStub, ExpandedPanelStub],
},
});
controller = TestBed.inject(HttpTestingController);
authService = TestBed.inject(TwitchAuthService);
});
afterEach(() => {
controller.verify();
delete (window as Window & { Twitch?: unknown }).Twitch;
vi.useRealTimers();
});
it('renders nothing before onAuthorized fires', () => {
(window as Window & { Twitch?: unknown }).Twitch = {
ext: {
onAuthorized: () => undefined,
onContext: () => undefined,
onVisibilityChanged: (cb: (v: boolean, ctx: TwitchContext) => void) => { visibilityCallback = cb; },
listen: () => undefined,
unlisten: () => undefined,
send: () => undefined,
},
};
const fixture = createComponent();
// Angular @if leaves comment nodes; check no element children are rendered
const shell = fixture.nativeElement.querySelector('app-panel-shell') as HTMLElement;
expect(shell.querySelectorAll('*').length).toBe(0);
});
it('shows onboarding when authorized but no active mission', () => {
mountTwitchExt();
const fixture = createComponent();
controller.expectOne(STATE_URL).flush(null);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('The fog stirs');
});
it('shows anonymous panel for A-prefixed opaque_user_id', () => {
mountTwitchExt(makeAnonymousJwt());
const fixture = createComponent();
controller.expectOne(STATE_URL).flush(null);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.anon-panel')).not.toBeNull();
});
it('shows minimised panel when mission state is available', () => {
mountTwitchExt();
const fixture = createComponent();
controller.expectOne(STATE_URL).flush(MISSION_STATE);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
});
it('toggles to expanded on lantern click and back on close', () => {
mountTwitchExt();
const fixture = createComponent();
controller.expectOne(STATE_URL).flush(MISSION_STATE);
fixture.detectChanges();
// Emit lanternClick from the stub component
fixture.debugElement.query(By.directive(MinimisedPanelStub))
.componentInstance.lanternClick.emit();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-expanded-panel')).not.toBeNull();
// Emit close from the expanded stub
fixture.debugElement.query(By.directive(ExpandedPanelStub))
.componentInstance.close.emit();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
});
it('shows ambient event on injury and auto-dismisses after 4s', async () => {
vi.useFakeTimers();
mountTwitchExt();
const fixture = createComponent();
controller.expectOne(STATE_URL).flush(MISSION_STATE);
fixture.detectChanges();
const injuredState = {
...MISSION_STATE,
survivors: [{ ...SURVIVOR, state: 'injured' as const }],
};
controller.expectNone(STATE_URL);
// Simulate a PubSub tick arriving that updates state to injured
controller.expectNone(STATE_URL);
// Directly set state by triggering a second fetch with an injured survivor
// (In production this comes from PubSub → store.refresh() → REST)
// We trigger via store.refresh() here
TestBed.inject(
(await import('../mission/mission-state.store')).MissionStateStore
).refresh();
controller.expectOne(STATE_URL).flush(injuredState);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-ambient-event')).not.toBeNull();
await vi.advanceTimersByTimeAsync(4000);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
});
it('drops ambient event when overlay is hidden', async () => {
mountTwitchExt();
const fixture = createComponent();
controller.expectOne(STATE_URL).flush(MISSION_STATE);
fixture.detectChanges();
visibilityCallback!(false, {} as TwitchContext);
const injuredState = {
...MISSION_STATE,
survivors: [{ ...SURVIVOR, state: 'injured' as const }],
};
TestBed.inject(
(await import('../mission/mission-state.store')).MissionStateStore
).refresh();
controller.expectOne(STATE_URL).flush(injuredState);
fixture.detectChanges();
// Still minimised — ambient event dropped while hidden
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
});
});

View File

@@ -0,0 +1,177 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
effect,
inject,
OnInit,
signal,
untracked,
} from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
import { distinctUntilChanged, filter, skip } from 'rxjs';
import { EbsApiService } from '../ebs/ebs-api.service';
import { MissionStateStore } from '../mission/mission-state.store';
import { TwitchAuthService } from '../twitch/twitch-auth.service';
import { AmbientEventComponent, AmbientEventData } from './ambient-event.component';
import { ExpandedPanelComponent } from './expanded-panel.component';
import { MinimisedPanelComponent } from './minimised-panel.component';
type OverlayView = 'minimised' | 'ambient' | 'expanded';
function detectAmbientEvent(
prev: NonNullable<MissionStateResponse>,
curr: NonNullable<MissionStateResponse>
): AmbientEventData | null {
if (curr.mission.status === 'success' && prev.mission.status !== 'success') {
return { type: 'mission-complete', description: 'Mission complete.' };
}
if (curr.mission.status === 'sacrifice' && prev.mission.status !== 'sacrifice') {
return { type: 'sacrifice', description: 'Sacrificed to the fog.' };
}
for (const survivor of curr.survivors) {
const prevSurvivor = prev.survivors.find((s) => s.id === survivor.id);
if (prevSurvivor && survivor.state !== prevSurvivor.state) {
if (survivor.state === 'injured' || survivor.state === 'downed') {
return { type: 'injury', description: `${survivor.name} is injured.` };
}
}
}
return null;
}
@Component({
selector: 'app-panel-shell',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MinimisedPanelComponent, AmbientEventComponent, ExpandedPanelComponent],
template: `
@if (authService.auth()) {
@if (!authService.isLoggedIn()) {
<div class="anon-panel">
<div class="lantern-desaturated"></div>
</div>
} @else if (!store.state()) {
<div class="onboarding-panel">
<p class="onboarding-text">The fog stirs. Awaiting a survivor.</p>
</div>
} @else {
@switch (view()) {
@case ('minimised') {
<app-minimised-panel
[missionState]="store.state()!"
(lanternClick)="onLanternClick()"
/>
}
@case ('ambient') {
<app-ambient-event
[event]="pendingEvent()!"
(dismissed)="onAmbientDismiss()"
/>
}
@case ('expanded') {
<app-expanded-panel
[missionState]="store.state()!"
(close)="onLanternClick()"
/>
}
}
}
}
`,
styles: [`
:host { display: block; position: fixed; bottom: 16px; left: 16px; z-index: 9999; }
.anon-panel, .onboarding-panel {
width: 290px;
min-height: 56px;
background: rgba(15, 18, 22, 0.88);
padding: 12px 16px;
box-sizing: border-box;
}
.lantern-desaturated {
width: 48px;
height: 48px;
border-radius: 50%;
background: #2a2e34;
border: 3px solid #4a4e54;
}
.onboarding-text {
font-family: 'JetBrains Mono', 'IBM Plex Mono', monospace;
font-size: 12px;
color: #6a7080;
margin: 0;
font-style: italic;
}
`],
})
export class PanelShellComponent implements OnInit {
protected readonly authService = inject(TwitchAuthService);
protected readonly store = inject(MissionStateStore);
// EbsApiService eagerly resolved to ensure EBS_BASE_URL token is
// available before any child component triggers a request.
private readonly _ebs = inject(EbsApiService);
private readonly destroyRef = inject(DestroyRef);
protected readonly view = signal<OverlayView>('minimised');
protected readonly pendingEvent = signal<AmbientEventData | null>(null);
private prevState: MissionStateResponse = null;
private ambientTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
// Visibility restore → reconcile missed ticks.
// skip(1) prevents a double-fetch on startup (initial isVisible value is true).
toObservable(this.authService.isVisible).pipe(
distinctUntilChanged(),
skip(1),
filter(Boolean),
takeUntilDestroyed(this.destroyRef),
).subscribe(() => this.store.refresh());
// Detect significant state changes and trigger ambient events.
effect(() => {
const current = this.store.state();
untracked(() => this.handleStateChange(current));
});
}
ngOnInit(): void {
this.authService.init();
this.store.init();
}
protected onLanternClick(): void {
this.view.update((v) => (v === 'expanded' ? 'minimised' : 'expanded'));
}
protected onAmbientDismiss(): void {
this.clearAmbientTimer();
this.view.set('minimised');
this.pendingEvent.set(null);
}
private handleStateChange(current: MissionStateResponse): void {
const prev = this.prevState;
this.prevState = current;
if (!prev || !current) return;
if (this.view() === 'expanded') return; // Don't interrupt expanded view
if (!this.authService.isVisible()) return; // Drop events while hidden
const event = detectAmbientEvent(prev, current);
if (!event) return;
this.clearAmbientTimer();
this.pendingEvent.set(event);
this.view.set('ambient');
this.ambientTimer = setTimeout(() => this.onAmbientDismiss(), 4000);
}
private clearAmbientTimer(): void {
if (this.ambientTimer !== null) {
clearTimeout(this.ambientTimer);
this.ambientTimer = null;
}
}
}

View File

@@ -0,0 +1,107 @@
import { TwitchAuthService, TwitchJwtPayload } from './twitch-auth.service';
function makeJwt(payload: Partial<TwitchJwtPayload> = {}): string {
const full: TwitchJwtPayload = {
exp: Math.floor(Date.now() / 1000) + 3600,
opaque_user_id: 'U12345678',
user_id: '12345678',
channel_id: '987654321',
role: 'viewer',
...payload,
};
const encoded = btoa(JSON.stringify(full))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return `eyJhbGciOiJIUzI1NiJ9.${encoded}.sig`;
}
function makeTwitchAuth(payload: Partial<TwitchJwtPayload> = {}): TwitchAuth {
return {
channelId: payload.channel_id ?? '987654321',
clientId: 'test_client',
token: makeJwt(payload),
userId: payload.opaque_user_id ?? 'U12345678',
};
}
describe('TwitchAuthService', () => {
let service: TwitchAuthService;
let extCallbacks: {
onAuthorized?: (auth: TwitchAuth) => void;
onContext?: (ctx: TwitchContext, changed: (keyof TwitchContext)[]) => void;
onVisibilityChanged?: (visible: boolean, ctx: TwitchContext) => void;
};
beforeEach(() => {
extCallbacks = {};
(window as Window & { Twitch?: unknown }).Twitch = {
ext: {
onAuthorized: (cb: (auth: TwitchAuth) => void) => { extCallbacks.onAuthorized = cb; },
onContext: (cb: (ctx: TwitchContext, changed: (keyof TwitchContext)[]) => void) => { extCallbacks.onContext = cb; },
onVisibilityChanged: (cb: (visible: boolean, ctx: TwitchContext) => void) => { extCallbacks.onVisibilityChanged = cb; },
listen: () => undefined,
unlisten: () => undefined,
send: () => undefined,
},
};
service = new TwitchAuthService();
service.init();
});
afterEach(() => {
delete (window as Window & { Twitch?: unknown }).Twitch;
});
it('exposes null auth before onAuthorized fires', () => {
expect(service.auth()).toBeNull();
expect(service.jwtPayload()).toBeNull();
expect(service.isLoggedIn()).toBe(false);
expect(service.channelId()).toBeNull();
});
it('sets auth and decoded payload when onAuthorized fires', () => {
extCallbacks.onAuthorized!(makeTwitchAuth());
expect(service.auth()).not.toBeNull();
expect(service.jwtPayload()?.opaque_user_id).toBe('U12345678');
expect(service.jwtPayload()?.channel_id).toBe('987654321');
expect(service.channelId()).toBe('987654321');
});
it('reports isLoggedIn true for U-prefixed opaque_user_id', () => {
extCallbacks.onAuthorized!(makeTwitchAuth({ opaque_user_id: 'U99999999' }));
expect(service.isLoggedIn()).toBe(true);
});
it('reports isLoggedIn false for A-prefixed opaque_user_id', () => {
extCallbacks.onAuthorized!(makeTwitchAuth({ opaque_user_id: 'Aanonymous' }));
expect(service.isLoggedIn()).toBe(false);
});
it('defaults isVisible to true', () => {
expect(service.isVisible()).toBe(true);
});
it('updates isVisible when onVisibilityChanged fires', () => {
const ctx = {} as TwitchContext;
extCallbacks.onVisibilityChanged!(false, ctx);
expect(service.isVisible()).toBe(false);
extCallbacks.onVisibilityChanged!(true, ctx);
expect(service.isVisible()).toBe(true);
});
it('updates context when onContext fires', () => {
const ctx = { game: 'test-game' } as TwitchContext;
extCallbacks.onContext!(ctx, ['game']);
expect(service.context()?.game).toBe('test-game');
});
it('updates context from onVisibilityChanged', () => {
const ctx = { isFullScreen: true } as TwitchContext;
extCallbacks.onVisibilityChanged!(false, ctx);
expect(service.context()?.isFullScreen).toBe(true);
});
});

View File

@@ -0,0 +1,87 @@
import { computed, Injectable, isDevMode, signal } from '@angular/core';
export interface TwitchJwtPayload {
exp: number;
opaque_user_id: string;
user_id?: string;
channel_id: string;
role: 'viewer' | 'broadcaster' | 'external';
is_unlinked?: boolean;
pubsub_perms?: { listen?: string[]; send?: string[] };
}
function decodeJwtPayload(token: string): TwitchJwtPayload | null {
try {
const part = token.split('.')[1];
return JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/')));
} catch {
return null;
}
}
function buildDevAuth(): TwitchAuth {
const payload: TwitchJwtPayload = {
exp: Math.floor(Date.now() / 1000) + 3600,
opaque_user_id: 'UDEV000001',
user_id: 'dev_user_1',
channel_id: 'dev_channel_1',
role: 'viewer',
};
const encoded = btoa(JSON.stringify(payload))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return {
channelId: payload.channel_id,
clientId: 'dev_client_id',
token: `eyJhbGciOiJIUzI1NiJ9.${encoded}.dev`,
userId: payload.opaque_user_id,
};
}
@Injectable({ providedIn: 'root' })
export class TwitchAuthService {
private readonly _auth = signal<TwitchAuth | null>(null);
private readonly _context = signal<TwitchContext | null>(null);
private readonly _isVisible = signal(true);
readonly auth = this._auth.asReadonly();
readonly context = this._context.asReadonly();
readonly isVisible = this._isVisible.asReadonly();
readonly jwtPayload = computed(() => {
const auth = this._auth();
return auth ? decodeJwtPayload(auth.token) : null;
});
readonly isLoggedIn = computed(
() => this.jwtPayload()?.opaque_user_id.startsWith('U') ?? false,
);
readonly channelId = computed(() => this.jwtPayload()?.channel_id ?? null);
init(): void {
if (isDevMode() && !window.Twitch?.ext) {
if (typeof ngDevMode === 'undefined' || ngDevMode) {
console.warn('[TwitchAuth] Twitch SDK not found — using dev auth');
}
this._auth.set(buildDevAuth());
return;
}
if (window.Twitch) {
window.Twitch.ext.onAuthorized((auth) => {
this._auth.set(auth);
});
window.Twitch.ext.onContext((context) => {
this._context.set(context);
});
window.Twitch.ext.onVisibilityChanged((isVisible, context) => {
this._isVisible.set(isVisible);
this._context.set(context);
});
}
}
}

View File

@@ -0,0 +1,11 @@
import '@angular/compiler';
import { getTestBed } from '@angular/core/testing';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting(), {
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

47
apps/overlay/src/twitch-ext.d.ts vendored Normal file
View File

@@ -0,0 +1,47 @@
interface TwitchAuth {
channelId: string;
clientId: string;
token: string;
userId: string;
helixToken?: string;
}
interface TwitchContext {
game: string;
language: string;
mode: 'viewer' | 'dashboard' | 'config';
isFullScreen: boolean;
isPaused: boolean;
theme: 'light' | 'dark';
arePlayerControlsVisible: boolean;
bitrate: number;
bufferSize: number;
displayResolution: string;
videoCurrentTime: number;
videoDuration: number;
videoResolution: string;
hlsLatencyBroadcaster: number;
}
interface TwitchExt {
onAuthorized(callback: (auth: TwitchAuth) => void): void;
onContext(
callback: (context: TwitchContext, changed: (keyof TwitchContext)[]) => void
): void;
onVisibilityChanged(
callback: (isVisible: boolean, context: TwitchContext) => void
): void;
listen(
target: string,
callback: (target: string, contentType: string, message: string) => void
): void;
unlisten(
target: string,
callback: (target: string, contentType: string, message: string) => void
): void;
send(target: string, contentType: string, message: object): void;
}
interface Window {
Twitch?: { ext: TwitchExt };
}

View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vitest/config';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/overlay',
plugins: [nxViteTsPaths()],
test: {
name: 'overlay',
watch: false,
globals: true,
environment: 'happy-dom',
setupFiles: ['./src/test-setup.ts'],
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../coverage/apps/overlay',
provider: 'v8' as const,
},
},
}));