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}`,
);