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:
@@ -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 {}
|
||||
|
||||
72
apps/api/src/app/auth/twitch-jwt.guard.ts
Normal file
72
apps/api/src/app/auth/twitch-jwt.guard.ts
Normal 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;
|
||||
}
|
||||
171
apps/api/src/app/missions/encounter.service.ts
Normal file
171
apps/api/src/app/missions/encounter.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
63
apps/api/src/app/missions/group-synergy.service.ts
Normal file
63
apps/api/src/app/missions/group-synergy.service.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
87
apps/api/src/app/missions/mission-store.service.ts
Normal file
87
apps/api/src/app/missions/mission-store.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
48
apps/api/src/app/missions/missions.controller.ts
Normal file
48
apps/api/src/app/missions/missions.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
20
apps/api/src/app/missions/missions.module.ts
Normal file
20
apps/api/src/app/missions/missions.module.ts
Normal 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 {}
|
||||
75
apps/api/src/app/missions/missions.service.ts
Normal file
75
apps/api/src/app/missions/missions.service.ts
Normal 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)}`;
|
||||
}
|
||||
20
apps/api/src/app/redis/redis.module.ts
Normal file
20
apps/api/src/app/redis/redis.module.ts
Normal 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 {}
|
||||
9
apps/api/src/app/tick-engine/tick-engine.module.ts
Normal file
9
apps/api/src/app/tick-engine/tick-engine.module.ts
Normal 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 {}
|
||||
61
apps/api/src/app/tick-engine/tick.service.ts
Normal file
61
apps/api/src/app/tick-engine/tick.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 {}
|
||||
|
||||
68
apps/overlay/src/app/ebs/auth.interceptor.spec.ts
Normal file
68
apps/overlay/src/app/ebs/auth.interceptor.spec.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
9
apps/overlay/src/app/ebs/auth.interceptor.ts
Normal file
9
apps/overlay/src/app/ebs/auth.interceptor.ts
Normal 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}` } }));
|
||||
};
|
||||
78
apps/overlay/src/app/ebs/ebs-api.service.spec.ts
Normal file
78
apps/overlay/src/app/ebs/ebs-api.service.spec.ts
Normal 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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
41
apps/overlay/src/app/ebs/ebs-api.service.ts
Normal file
41
apps/overlay/src/app/ebs/ebs-api.service.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
159
apps/overlay/src/app/mission/mission-state.store.spec.ts
Normal file
159
apps/overlay/src/app/mission/mission-state.store.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
apps/overlay/src/app/mission/mission-state.store.ts
Normal file
58
apps/overlay/src/app/mission/mission-state.store.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
52
apps/overlay/src/app/panel/ambient-event.component.ts
Normal file
52
apps/overlay/src/app/panel/ambient-event.component.ts
Normal 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>();
|
||||
}
|
||||
129
apps/overlay/src/app/panel/expanded-panel.component.ts
Normal file
129
apps/overlay/src/app/panel/expanded-panel.component.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
78
apps/overlay/src/app/panel/minimised-panel.component.ts
Normal file
78
apps/overlay/src/app/panel/minimised-panel.component.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
253
apps/overlay/src/app/panel/panel-shell.component.spec.ts
Normal file
253
apps/overlay/src/app/panel/panel-shell.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
177
apps/overlay/src/app/panel/panel-shell.component.ts
Normal file
177
apps/overlay/src/app/panel/panel-shell.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
107
apps/overlay/src/app/twitch/twitch-auth.service.spec.ts
Normal file
107
apps/overlay/src/app/twitch/twitch-auth.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
87
apps/overlay/src/app/twitch/twitch-auth.service.ts
Normal file
87
apps/overlay/src/app/twitch/twitch-auth.service.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
11
apps/overlay/src/test-setup.ts
Normal file
11
apps/overlay/src/test-setup.ts
Normal 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
47
apps/overlay/src/twitch-ext.d.ts
vendored
Normal 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 };
|
||||
}
|
||||
21
apps/overlay/vitest.config.mts
Normal file
21
apps/overlay/vitest.config.mts
Normal 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,
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user