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}`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user