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

@@ -0,0 +1,10 @@
{
"version": 2,
"name": "frontend-angular",
"owner": "ai-enhanced-engineer",
"repo": "aiee-team",
"path": "skills/frontend-angular",
"branch": "main",
"sha": "9742140f3ba1399701796d131407d6f41983074f",
"source": "manual"
}

View File

@@ -0,0 +1,190 @@
---
name: frontend-angular
description: Modern Angular 21+ patterns including signals, standalone components, zoneless change detection, and new control flow syntax. Use for Angular architecture decisions or implementing components with latest APIs.
trigger-terms: Angular 21+, signals, standalone components, zoneless, control flow, @if, @for
---
# Angular Production Patterns
Production-ready patterns for Angular 21+ applications with zoneless change detection.
## Core Concepts
### Signal-Based Reactivity
```typescript
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<button (click)="increment()">
{{ count() }} × 2 = {{ doubled() }}
</button>
`
})
export class CounterComponent {
count = signal(0);
doubled = computed(() => this.count() * 2);
constructor() {
effect(() => console.log('Count changed:', this.count()));
}
increment() {
this.count.update(n => n + 1);
}
}
```
### Key APIs
| API | Purpose |
|-----|---------|
| `signal()` | Reactive state declaration |
| `computed()` | Derived values (auto-tracked) |
| `effect()` | Side effects on signal changes |
| `input()` | Component inputs (replaces @Input) |
| `output()` | Component outputs (replaces @Output) |
| `model()` | Two-way bindable signals |
## Modern Control Flow
```html
<!-- Conditionals -->
@if (isLoading()) {
<app-spinner />
} @else if (hasError()) {
<app-error [message]="error()" />
} @else {
<app-content [data]="data()" />
}
<!-- Loops with tracking -->
@for (item of items(); track item.id) {
<app-item [data]="item" />
} @empty {
<p>No items found</p>
}
<!-- Switch -->
@switch (status()) {
@case ('pending') { <app-pending /> }
@case ('active') { <app-active /> }
@default { <app-unknown /> }
}
```
## Standalone Architecture
```typescript
// No NgModules - components declare their dependencies
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule, RouterModule, MetricsComponent],
template: `...`
})
export class DashboardComponent {}
// Bootstrapping
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor]))
]
});
```
## Component I/O (Angular 21+)
```typescript
@Component({...})
export class UserCardComponent {
// Input signal (required)
user = input.required<User>();
// Input with default
showAvatar = input(true);
// Output
selected = output<User>();
// Two-way binding
isExpanded = model(false);
onSelect() {
this.selected.emit(this.user());
}
}
```
## Performance Targets
| Metric | Target |
|--------|--------|
| LCP | < 2.5s |
| INP | < 200ms |
| CLS | < 0.1 |
| Initial Bundle | < 250KB |
## When NOT to Use Angular
- Simple interactivity → Vanilla JS
- Static marketing site → Astro/11ty
- < 100KB JS budget → Svelte or Web Components
- React ecosystem dependency → React
## Signal Testing Patterns
| Pattern | Use Case |
|---------|----------|
| PLATFORM_ID mocking | Prevent constructor side effects in SSR tests |
| WritableSignal with `.set()` | Control signal state without reassignment |
| NG0100 prevention | Initialize signals before `detectChanges()` |
## Signal-Based Service Pattern
Private writable signal → public readonly → update in `tap()` → component injects and reads:
```typescript
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
private _summary = signal<Summary | null>(null);
readonly summary = this._summary.asReadonly();
private _loading = signal(false);
readonly loading = this._loading.asReadonly();
getSummary() {
this._loading.set(true);
return this.http.get<Summary>('/api/analytics/summary').pipe(
tap(data => {
this._summary.set(data);
this._loading.set(false);
}),
catchError(err => {
this._loading.set(false);
throw err;
})
);
}
}
```
**Component usage:**
```typescript
@Component({...})
export class DashboardComponent {
private analytics = inject(AnalyticsService);
summary = this.analytics.summary;
loading = this.analytics.loading;
}
```
**Pattern:** Matches AuthService pattern for consistency across services.
See `examples.md` for full testing code patterns.
See `reference.md` for component libraries.

View File

@@ -0,0 +1,83 @@
# Angular Examples
Production-ready component implementations for Angular 21+.
## Dashboard Metrics Component
```typescript
import { Component, input, computed } from '@angular/core';
import { DecimalPipe, PercentPipe } from '@angular/common';
interface Metric {
label: string;
value: number;
format: 'number' | 'currency' | 'percent';
trend: 'up' | 'down' | 'stable';
}
@Component({
selector: 'app-metrics-widget',
standalone: true,
imports: [DecimalPipe, PercentPipe],
template: `
<div class="metrics-grid" role="region" aria-labelledby="metrics-heading">
<h2 id="metrics-heading" class="sr-only">Key Metrics</h2>
@for (metric of metrics(); track metric.label) {
<article
class="metric-card"
[attr.aria-label]="metric.label + ': ' + formatValue(metric)">
<span class="label">{{ metric.label }}</span>
<span class="value">{{ formatValue(metric) }}</span>
<span class="trend" [class]="metric.trend">
@switch (metric.trend) {
@case ('up') { ↑ }
@case ('down') { ↓ }
@default { → }
}
</span>
</article>
}
</div>
`,
styles: [`
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.metric-card {
padding: 1.5rem;
border-radius: 8px;
background: var(--surface-card);
box-shadow: var(--shadow-sm);
}
.trend.up { color: var(--green-500); }
.trend.down { color: var(--red-500); }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
`]
})
export class MetricsWidgetComponent {
metrics = input.required<Metric[]>();
formatValue(metric: Metric): string {
switch (metric.format) {
case 'currency':
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(metric.value);
case 'percent':
return `${(metric.value * 100).toFixed(1)}%`;
default:
return metric.value.toLocaleString();
}
}
}
```

View File

@@ -0,0 +1,174 @@
# Angular Reference
Detailed patterns and component library guidance for Angular 21+.
## Zoneless Change Detection (Default in v21)
```typescript
// No Zone.js needed - signals trigger updates automatically
bootstrapApplication(AppComponent, {
providers: [
// Zoneless is default in Angular 21+
// Only add this if you need Zone.js for legacy code:
// provideZoneChangeDetection()
]
});
```
**Benefits:**
- Smaller bundle (no Zone.js ~100KB)
- Predictable change detection
- Better debugging (no Zone.js stack traces)
- Signals automatically schedule updates
## Dependency Injection
```typescript
// Modern inject() function (preferred)
@Component({...})
export class UserService {
private http = inject(HttpClient);
private router = inject(Router);
}
// Constructor injection (still valid)
constructor(private http: HttpClient) {}
```
## HTTP Client Patterns
```typescript
// Functional interceptors (Angular 21+)
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).getToken();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
}
return next(req);
};
// Registration
provideHttpClient(
withInterceptors([authInterceptor, loggingInterceptor])
)
```
## Resource API (Async Data)
```typescript
// Signal-based async data loading
@Component({...})
export class UsersComponent {
private usersService = inject(UsersService);
searchQuery = signal('');
usersResource = resource({
request: () => ({ query: this.searchQuery() }),
loader: async ({ request }) => {
return this.usersService.search(request.query);
}
});
// In template:
// @if (usersResource.isLoading()) { ... }
// @if (usersResource.hasValue()) { ... }
// @if (usersResource.error()) { ... }
}
```
## Component Libraries Comparison
| Library | Cost | Components | Best For |
|---------|------|------------|----------|
| **PrimeNG** | Free (MIT) | 80+ | B2B dashboards, forms |
| **Kendo UI** | $1,028+/dev/year | 110+ | Enterprise, data grids |
| **Angular Material** | Free | ~40 | Material Design apps |
## Reactive Forms with Signals
```typescript
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
@Component({
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email" />
@if (form.controls.email.errors?.['required']) {
<span class="error">Email is required</span>
}
<button type="submit" [disabled]="form.invalid">Submit</button>
</form>
`
})
export class LoginComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]]
});
onSubmit() {
if (this.form.valid) {
console.log(this.form.value);
}
}
}
```
## Router Patterns
```typescript
// Route configuration
export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'dashboard',
loadComponent: () => import('./dashboard/dashboard.component')
.then(m => m.DashboardComponent),
canActivate: [authGuard]
},
{ path: '**', component: NotFoundComponent }
];
// Functional guard
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated()) {
return true;
}
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
};
```
## Error Handling
```typescript
// Global error handler
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
private snackBar = inject(MatSnackBar);
handleError(error: Error) {
console.error('Unhandled error:', error);
this.snackBar.open('An error occurred', 'Dismiss', {
duration: 5000
});
}
}
// Provider
{ provide: ErrorHandler, useClass: GlobalErrorHandler }
```

View File

@@ -1,4 +1,15 @@
{ {
"permissions": {
"allow": [
"Bash(node -e *)",
"Bash(node --input-type=module)",
"Bash(2>&1)",
"Bash(pnpm *)"
]
},
"enabledPlugins": {
"nx@nx-claude-plugins": true
},
"extraKnownMarketplaces": { "extraKnownMarketplaces": {
"nx-claude-plugins": { "nx-claude-plugins": {
"source": { "source": {
@@ -6,8 +17,5 @@
"repo": "nrwl/nx-ai-agents-config" "repo": "nrwl/nx-ai-agents-config"
} }
} }
},
"enabledPlugins": {
"nx@nx-claude-plugins": true
} }
} }

View File

@@ -0,0 +1,10 @@
{
"version": 2,
"name": "frontend-angular",
"owner": "ai-enhanced-engineer",
"repo": "aiee-team",
"path": "skills/frontend-angular",
"branch": "main",
"sha": "9742140f3ba1399701796d131407d6f41983074f",
"source": "manual"
}

View File

@@ -0,0 +1,190 @@
---
name: frontend-angular
description: Modern Angular 21+ patterns including signals, standalone components, zoneless change detection, and new control flow syntax. Use for Angular architecture decisions or implementing components with latest APIs.
trigger-terms: Angular 21+, signals, standalone components, zoneless, control flow, @if, @for
---
# Angular Production Patterns
Production-ready patterns for Angular 21+ applications with zoneless change detection.
## Core Concepts
### Signal-Based Reactivity
```typescript
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<button (click)="increment()">
{{ count() }} × 2 = {{ doubled() }}
</button>
`
})
export class CounterComponent {
count = signal(0);
doubled = computed(() => this.count() * 2);
constructor() {
effect(() => console.log('Count changed:', this.count()));
}
increment() {
this.count.update(n => n + 1);
}
}
```
### Key APIs
| API | Purpose |
|-----|---------|
| `signal()` | Reactive state declaration |
| `computed()` | Derived values (auto-tracked) |
| `effect()` | Side effects on signal changes |
| `input()` | Component inputs (replaces @Input) |
| `output()` | Component outputs (replaces @Output) |
| `model()` | Two-way bindable signals |
## Modern Control Flow
```html
<!-- Conditionals -->
@if (isLoading()) {
<app-spinner />
} @else if (hasError()) {
<app-error [message]="error()" />
} @else {
<app-content [data]="data()" />
}
<!-- Loops with tracking -->
@for (item of items(); track item.id) {
<app-item [data]="item" />
} @empty {
<p>No items found</p>
}
<!-- Switch -->
@switch (status()) {
@case ('pending') { <app-pending /> }
@case ('active') { <app-active /> }
@default { <app-unknown /> }
}
```
## Standalone Architecture
```typescript
// No NgModules - components declare their dependencies
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule, RouterModule, MetricsComponent],
template: `...`
})
export class DashboardComponent {}
// Bootstrapping
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor]))
]
});
```
## Component I/O (Angular 21+)
```typescript
@Component({...})
export class UserCardComponent {
// Input signal (required)
user = input.required<User>();
// Input with default
showAvatar = input(true);
// Output
selected = output<User>();
// Two-way binding
isExpanded = model(false);
onSelect() {
this.selected.emit(this.user());
}
}
```
## Performance Targets
| Metric | Target |
|--------|--------|
| LCP | < 2.5s |
| INP | < 200ms |
| CLS | < 0.1 |
| Initial Bundle | < 250KB |
## When NOT to Use Angular
- Simple interactivity → Vanilla JS
- Static marketing site → Astro/11ty
- < 100KB JS budget → Svelte or Web Components
- React ecosystem dependency → React
## Signal Testing Patterns
| Pattern | Use Case |
|---------|----------|
| PLATFORM_ID mocking | Prevent constructor side effects in SSR tests |
| WritableSignal with `.set()` | Control signal state without reassignment |
| NG0100 prevention | Initialize signals before `detectChanges()` |
## Signal-Based Service Pattern
Private writable signal → public readonly → update in `tap()` → component injects and reads:
```typescript
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
private _summary = signal<Summary | null>(null);
readonly summary = this._summary.asReadonly();
private _loading = signal(false);
readonly loading = this._loading.asReadonly();
getSummary() {
this._loading.set(true);
return this.http.get<Summary>('/api/analytics/summary').pipe(
tap(data => {
this._summary.set(data);
this._loading.set(false);
}),
catchError(err => {
this._loading.set(false);
throw err;
})
);
}
}
```
**Component usage:**
```typescript
@Component({...})
export class DashboardComponent {
private analytics = inject(AnalyticsService);
summary = this.analytics.summary;
loading = this.analytics.loading;
}
```
**Pattern:** Matches AuthService pattern for consistency across services.
See `examples.md` for full testing code patterns.
See `reference.md` for component libraries.

View File

@@ -0,0 +1,83 @@
# Angular Examples
Production-ready component implementations for Angular 21+.
## Dashboard Metrics Component
```typescript
import { Component, input, computed } from '@angular/core';
import { DecimalPipe, PercentPipe } from '@angular/common';
interface Metric {
label: string;
value: number;
format: 'number' | 'currency' | 'percent';
trend: 'up' | 'down' | 'stable';
}
@Component({
selector: 'app-metrics-widget',
standalone: true,
imports: [DecimalPipe, PercentPipe],
template: `
<div class="metrics-grid" role="region" aria-labelledby="metrics-heading">
<h2 id="metrics-heading" class="sr-only">Key Metrics</h2>
@for (metric of metrics(); track metric.label) {
<article
class="metric-card"
[attr.aria-label]="metric.label + ': ' + formatValue(metric)">
<span class="label">{{ metric.label }}</span>
<span class="value">{{ formatValue(metric) }}</span>
<span class="trend" [class]="metric.trend">
@switch (metric.trend) {
@case ('up') { ↑ }
@case ('down') { ↓ }
@default { → }
}
</span>
</article>
}
</div>
`,
styles: [`
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.metric-card {
padding: 1.5rem;
border-radius: 8px;
background: var(--surface-card);
box-shadow: var(--shadow-sm);
}
.trend.up { color: var(--green-500); }
.trend.down { color: var(--red-500); }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
`]
})
export class MetricsWidgetComponent {
metrics = input.required<Metric[]>();
formatValue(metric: Metric): string {
switch (metric.format) {
case 'currency':
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(metric.value);
case 'percent':
return `${(metric.value * 100).toFixed(1)}%`;
default:
return metric.value.toLocaleString();
}
}
}
```

View File

@@ -0,0 +1,174 @@
# Angular Reference
Detailed patterns and component library guidance for Angular 21+.
## Zoneless Change Detection (Default in v21)
```typescript
// No Zone.js needed - signals trigger updates automatically
bootstrapApplication(AppComponent, {
providers: [
// Zoneless is default in Angular 21+
// Only add this if you need Zone.js for legacy code:
// provideZoneChangeDetection()
]
});
```
**Benefits:**
- Smaller bundle (no Zone.js ~100KB)
- Predictable change detection
- Better debugging (no Zone.js stack traces)
- Signals automatically schedule updates
## Dependency Injection
```typescript
// Modern inject() function (preferred)
@Component({...})
export class UserService {
private http = inject(HttpClient);
private router = inject(Router);
}
// Constructor injection (still valid)
constructor(private http: HttpClient) {}
```
## HTTP Client Patterns
```typescript
// Functional interceptors (Angular 21+)
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).getToken();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
}
return next(req);
};
// Registration
provideHttpClient(
withInterceptors([authInterceptor, loggingInterceptor])
)
```
## Resource API (Async Data)
```typescript
// Signal-based async data loading
@Component({...})
export class UsersComponent {
private usersService = inject(UsersService);
searchQuery = signal('');
usersResource = resource({
request: () => ({ query: this.searchQuery() }),
loader: async ({ request }) => {
return this.usersService.search(request.query);
}
});
// In template:
// @if (usersResource.isLoading()) { ... }
// @if (usersResource.hasValue()) { ... }
// @if (usersResource.error()) { ... }
}
```
## Component Libraries Comparison
| Library | Cost | Components | Best For |
|---------|------|------------|----------|
| **PrimeNG** | Free (MIT) | 80+ | B2B dashboards, forms |
| **Kendo UI** | $1,028+/dev/year | 110+ | Enterprise, data grids |
| **Angular Material** | Free | ~40 | Material Design apps |
## Reactive Forms with Signals
```typescript
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
@Component({
standalone: true,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email" />
@if (form.controls.email.errors?.['required']) {
<span class="error">Email is required</span>
}
<button type="submit" [disabled]="form.invalid">Submit</button>
</form>
`
})
export class LoginComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]]
});
onSubmit() {
if (this.form.valid) {
console.log(this.form.value);
}
}
}
```
## Router Patterns
```typescript
// Route configuration
export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'dashboard',
loadComponent: () => import('./dashboard/dashboard.component')
.then(m => m.DashboardComponent),
canActivate: [authGuard]
},
{ path: '**', component: NotFoundComponent }
];
// Functional guard
export const authGuard: CanActivateFn = (route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated()) {
return true;
}
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url }
});
};
```
## Error Handling
```typescript
// Global error handler
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
private snackBar = inject(MatSnackBar);
handleError(error: Error) {
console.error('Unhandled error:', error);
this.snackBar.open('An error occurred', 'Dismiss', {
duration: 5000
});
}
}
// Provider
{ provide: ErrorHandler, useClass: GlobalErrorHandler }
```

View File

@@ -145,7 +145,7 @@ Separate from the viewer overlay. Live preview of the lantern in all four corner
- Initialise Nx workspace with TypeScript, `@nx/angular` (or `@nx/js` if dropping Angular), `@nx/nest`, `@nx/node`. - Initialise Nx workspace with TypeScript, `@nx/angular` (or `@nx/js` if dropping Angular), `@nx/nest`, `@nx/node`.
- Generate apps: overlay frontend, NestJS API. - Generate apps: overlay frontend, NestJS API.
- Generate `@fog-explorer/api-interfaces` lib with Zod schemas for `Survivor`, `SurvivorState`, `Mission`, `MissionState`, `EncounterResult`, `Perk`, `PerkModifier`, `TeamPerk`. - Generate `@fog-explorer/api-interfaces` lib with Zod schemas for `Survivor`, `SurvivorState`, `Mission`, `MissionState`, `EncounterResult`, `Perk`, `PerkModifier`.
- Configure `@nx/enforce-module-boundaries` with project tags (`scope:shared`, `scope:api`, `scope:overlay`). AI assistants love to reach across libraries with relative imports — enforce early. - Configure `@nx/enforce-module-boundaries` with project tags (`scope:shared`, `scope:api`, `scope:overlay`). AI assistants love to reach across libraries with relative imports — enforce early.
- Root `docker-compose.yml`: Postgres (named `fog_expedition`, healthcheck), Redis, optional API service. - Root `docker-compose.yml`: Postgres (named `fog_expedition`, healthcheck), Redis, optional API service.
- `.devcontainer/` directory with config (see Section 7). - `.devcontainer/` directory with config (see Section 7).
@@ -167,7 +167,7 @@ Separate from the viewer overlay. Live preview of the lantern in all four corner
- Distributed lock pattern: `SET key value NX PX <ttl>` with unique token, verify token before release. Or use Redlock. Naive `SETNX` without TTL = stuck tick on crash. - Distributed lock pattern: `SET key value NX PX <ttl>` with unique token, verify token before release. Or use Redlock. Naive `SETNX` without TTL = stuck tick on crash.
- **Encounter resolver — pure function** in `libs/mission-logic`. Signature `resolveEncounter(survivor, perks, difficulty, seed) → EncounterResult`. Use seeded PRNG (`seedrandom`), not `Math.random()`. Store seed per tick in `mission_logs` for replay/debug. - **Encounter resolver — pure function** in `libs/mission-logic`. Signature `resolveEncounter(survivor, perks, difficulty, seed) → EncounterResult`. Use seeded PRNG (`seedrandom`), not `Math.random()`. Store seed per tick in `mission_logs` for replay/debug.
- `EncounterService` calls the resolver, updates state, emits log events. - `EncounterService` calls the resolver, updates state, emits log events.
- `GroupSynergyService` aggregates team perks for SWF missions, applies to all relevant rolls. - `GroupSynergyService` aggregates each participant's survivor perks for SWF missions, applies combined modifiers to all relevant rolls.
- `MissionStore` repository abstracting Redis. Keys: `active_mission:{id}`, `mission_lobby:{id}`. - `MissionStore` repository abstracting Redis. Keys: `active_mission:{id}`, `mission_lobby:{id}`.
### Stage 4 — EBS persistence ### Stage 4 — EBS persistence
@@ -261,7 +261,7 @@ This project is being built with heavy AI assistance. A few conventions to keep
Things deliberately not decided yet — flag them when you reach them: Things deliberately not decided yet — flag them when you reach them:
- **Overlay framework:** Angular (per original plan) vs Lit vs vanilla TS. Decide before Stage 2 starts. - **Overlay framework:** Decided — Angular. See [ADR-0008](docs/adr/0008-angular-for-overlay-frontend.md).
- **ORM:** TypeORM vs Prisma vs Drizzle. Pick one before any DB code is written. - **ORM:** TypeORM vs Prisma vs Drizzle. Pick one before any DB code is written.
- **Group/SWF expanded UI:** designed conceptually, not mocked. - **Group/SWF expanded UI:** designed conceptually, not mocked.
- **Streamer config screen:** designed conceptually, not mocked. - **Streamer config screen:** designed conceptually, not mocked.

View File

@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AppController } from './app.controller'; import { ScheduleModule } from '@nestjs/schedule';
import { AppService } from './app.service'; import { MissionsModule } from './missions/missions.module';
import { TickEngineModule } from './tick-engine/tick-engine.module';
@Module({ @Module({
imports: [], imports: [
controllers: [AppController], ScheduleModule.forRoot(),
providers: [AppService], MissionsModule,
TickEngineModule,
],
}) })
export class AppModule {} 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'; const globalPrefix = 'api';
app.setGlobalPrefix(globalPrefix); app.setGlobalPrefix(globalPrefix);
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
await app.listen(port); await app.listen(port, '0.0.0.0');
Logger.log( Logger.log(
`🚀 Application is running on: http://localhost:${port}/${globalPrefix}`, `🚀 Application is running on: http://localhost:${port}/${globalPrefix}`,
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -0,0 +1,44 @@
# 0008 — Angular for the overlay frontend
- **Status:** Accepted
- **Date:** 2026-05-07
## Context and problem statement
`PROJECT_CONTEXT.md` explicitly deferred the overlay framework choice to Stage 2, noting that Vanilla TypeScript + Lit "is likely a better fit" for bundle size and Twitch review simplicity. The overlay app was scaffolded with Angular as a placeholder. A decision is required before building any real overlay components.
## Decision drivers
- **Bundle size.** Twitch reviews extensions manually; a smaller bundle reduces scrutiny risk and load time over arbitrary stream content.
- **Developer velocity.** Familiarity with a framework dominates iteration speed, especially for a solo build.
- **Component model fit.** The overlay has three display states and a PubSub subscription — straightforward enough for any framework.
- **Existing scaffold.** The Angular app already exists in the workspace; switching adds setup cost with no immediate payoff.
## Considered options
1. **Angular.** Full framework; already scaffolded; developer is expert-level.
2. **Lit.** Web-component library; ~1520 KB; closer to the platform.
3. **Vanilla TypeScript (hand-rolled).** Minimal; total control; no dependency; highest maintenance burden for reactivity.
## Decision outcome
**Chosen: Angular.**
The developer is very familiar with Angular and unfamiliar with Lit. Velocity matters more than the marginal bundle-size improvement. Angular's production build with `@angular/build` treeshakes aggressively; the overlay's initial budget is set to 1 MB error / 500 KB warning in `project.json`, which is comfortably achievable. The three overlay states map naturally to standalone Angular components with signals for reactive state.
## Consequences
### Positive
- No framework ramp-up time; patterns (signals, standalone components, `HttpClient`, RxJS) are immediately available.
- `@angular/build` produces well-optimised production bundles with differential loading and full treeshaking.
- Existing workspace tooling (`@nx/angular`, ESLint, Vitest via `vitest-angular`) works without additional configuration.
### Negative
- Baseline bundle is larger than Lit or vanilla (~120200 KB gzipped vs ~1520 KB). Mitigate with lazy routes if the expanded view grows significantly.
- If Twitch introduces strict size limits in a future review cycle, migrating is non-trivial.
### Neutral
- Revisit if the production bundle consistently exceeds 400 KB gzipped after Stage 2 is complete. At that point, incremental Lit migration (replacing individual components) is feasible without a full rewrite.

View File

@@ -24,6 +24,7 @@ We use [MADR](https://adr.github.io/madr/) (Markdown ADR) format. Each record ha
| [0005](./0005-per-mission-jittered-tick-scheduling.md) | Per-mission jittered tick scheduling | Accepted | | [0005](./0005-per-mission-jittered-tick-scheduling.md) | Per-mission jittered tick scheduling | Accepted |
| [0006](./0006-postgres-plus-redis-data-split.md) | PostgreSQL for durable state, Redis for ephemeral | Accepted | | [0006](./0006-postgres-plus-redis-data-split.md) | PostgreSQL for durable state, Redis for ephemeral | Accepted |
| [0007](./0007-devcontainer-with-host-services.md) | Devcontainer with docker-outside-of-docker, services on host | Accepted | | [0007](./0007-devcontainer-with-host-services.md) | Devcontainer with docker-outside-of-docker, services on host | Accepted |
| [0008](./0008-angular-for-overlay-frontend.md) | Angular for the overlay frontend | Accepted |
## When to write a new ADR ## When to write a new ADR

View File

@@ -6,8 +6,9 @@
"main": "./src/index.js", "main": "./src/index.js",
"types": "./src/index.d.ts", "types": "./src/index.d.ts",
"dependencies": { "dependencies": {
"@nx/vite": "^22.7.1",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"vitest": "^4.0.8", "vitest": "^4.0.8",
"@nx/vite": "^22.7.1" "zod": "^4.4.3"
} }
} }

View File

@@ -2,3 +2,5 @@ export * from './lib/perk';
export * from './lib/survivor'; export * from './lib/survivor';
export * from './lib/mission'; export * from './lib/mission';
export * from './lib/encounter'; export * from './lib/encounter';
export * from './lib/encounter-definition';
export * from './lib/mission-state';

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const EncounterDefinitionSchema = z.object({
key: z.string().min(1),
baseProbability: z.number().min(0).max(1),
tags: z.array(z.string()),
});
export type EncounterDefinition = z.infer<typeof EncounterDefinitionSchema>;

View File

@@ -0,0 +1,23 @@
import { z } from 'zod';
import { EncounterResultSchema } from './encounter';
import { MissionSchema } from './mission';
import { SurvivorSchema } from './survivor';
export const MissionStateResponseSchema = z
.object({
mission: MissionSchema,
survivors: z.array(SurvivorSchema),
recentLog: z.array(EncounterResultSchema).max(20),
})
.nullable();
export type MissionStateResponse = z.infer<typeof MissionStateResponseSchema>;
export const StartMissionRequestSchema = z.object({
difficulty: z.number().int().min(1).max(3),
});
export type StartMissionRequest = z.infer<typeof StartMissionRequestSchema>;
export const ChoosePerkRequestSchema = z.object({
perkKey: z.string().min(1),
});
export type ChoosePerkRequest = z.infer<typeof ChoosePerkRequestSchema>;

View File

@@ -11,6 +11,7 @@ export default [
ignoredFiles: [ ignoredFiles: [
'{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
'{projectRoot}/vite.config.{js,ts,mjs,mts}', '{projectRoot}/vite.config.{js,ts,mjs,mts}',
'{projectRoot}/vitest.config.{js,ts,mjs,mts}',
], ],
}, },
], ],

View File

@@ -7,6 +7,9 @@
"types": "./src/index.d.ts", "types": "./src/index.d.ts",
"dependencies": { "dependencies": {
"tslib": "^2.3.0", "tslib": "^2.3.0",
"@fog-explorer/api-interfaces": "*"
},
"devDependencies": {
"vitest": "^4.0.8", "vitest": "^4.0.8",
"@nx/vite": "^22.7.1" "@nx/vite": "^22.7.1"
} }

View File

@@ -12,7 +12,14 @@
"outputPath": "dist/libs/encounter-library", "outputPath": "dist/libs/encounter-library",
"main": "libs/encounter-library/src/index.ts", "main": "libs/encounter-library/src/index.ts",
"tsConfig": "libs/encounter-library/tsconfig.lib.json", "tsConfig": "libs/encounter-library/tsconfig.lib.json",
"assets": ["libs/encounter-library/*.md"] "assets": [
"libs/encounter-library/*.md",
{
"input": "libs/encounter-library/src/lib",
"output": "src/lib",
"glob": "*.json"
}
]
} }
} }
} }

View File

@@ -1,7 +1,64 @@
import { encounterLibrary } from './encounter-library'; import {
getEncounterById,
getEncountersByTier,
getLibraryVersion,
getRandomEncounterByTier,
pickFlavor,
} from './encounter-library';
describe('encounterLibrary', () => { function seqRng(values: number[]): () => number {
it('should work', () => { let i = 0;
expect(encounterLibrary()).toEqual('encounter-library'); return () => values[i++ % values.length];
}
describe('encounter-library', () => {
it('getLibraryVersion returns a semver string', () => {
expect(getLibraryVersion()).toMatch(/^\d+\.\d+\.\d+$/);
});
it('getEncounterById returns the correct encounter', () => {
const enc = getEncounterById('generator_repair');
expect(enc?.key).toBe('generator_repair');
expect(enc?.baseProbability).toBeGreaterThan(0);
expect(enc?.tags).toContain('generator');
});
it('getEncounterById returns undefined for unknown key', () => {
expect(getEncounterById('does_not_exist')).toBeUndefined();
});
it('getEncountersByTier returns only encounters of that tier', () => {
const tier1 = getEncountersByTier(1);
expect(tier1.every((e) => e.tier === 1)).toBe(true);
expect(tier1.length).toBeGreaterThan(0);
});
it('getRandomEncounterByTier returns an encounter from the tier', () => {
const rng = seqRng([0, 0.5, 0.99]);
for (let i = 0; i < 3; i++) {
const enc = getRandomEncounterByTier(1, rng);
expect(enc.tier).toBe(1);
}
});
it('pickFlavor returns a success flavor on success', () => {
const enc = getEncounterById('generator_repair')!;
const flavor = pickFlavor(enc, { success: true }, seqRng([0]));
expect(enc.flavorSuccess).toContain(flavor);
});
it('pickFlavor returns a failure flavor on failure', () => {
const enc = getEncounterById('generator_repair')!;
const flavor = pickFlavor(enc, { success: false }, seqRng([0]));
expect(enc.flavorFailure).toContain(flavor);
});
it('all encounters have non-empty flavor arrays', () => {
for (const tier of [1, 2, 3] as const) {
for (const enc of getEncountersByTier(tier)) {
expect(enc.flavorSuccess.length).toBeGreaterThan(0);
expect(enc.flavorFailure.length).toBeGreaterThan(0);
}
}
}); });
}); });

View File

@@ -1,3 +1,70 @@
export function encounterLibrary(): string { import type { EncounterDefinition } from '@fog-explorer/api-interfaces';
return 'encounter-library';
interface RawEncounter {
key: string;
baseProbability: number;
tags: string[];
tier: number;
flavorSuccess: string[];
flavorFailure: string[];
}
interface EncountersFile {
version: string;
encounters: RawEncounter[];
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const encountersData = require('./encounters.json') as EncountersFile;
export interface LibraryEncounter extends EncounterDefinition {
tier: 1 | 2 | 3;
flavorSuccess: string[];
flavorFailure: string[];
}
export interface FlavorContext {
success: boolean;
}
const LIBRARY_VERSION: string = encountersData.version;
const ALL_ENCOUNTERS: LibraryEncounter[] = encountersData.encounters.map((e) => ({
key: e.key,
baseProbability: e.baseProbability,
tags: e.tags,
tier: e.tier as 1 | 2 | 3,
flavorSuccess: e.flavorSuccess,
flavorFailure: e.flavorFailure,
}));
export function getLibraryVersion(): string {
return LIBRARY_VERSION;
}
export function getEncounterById(key: string): LibraryEncounter | undefined {
return ALL_ENCOUNTERS.find((e) => e.key === key);
}
export function getEncountersByTier(tier: 1 | 2 | 3): LibraryEncounter[] {
return ALL_ENCOUNTERS.filter((e) => e.tier === tier);
}
export function getRandomEncounterByTier(
tier: 1 | 2 | 3,
rng: () => number
): LibraryEncounter {
const pool = getEncountersByTier(tier);
if (pool.length === 0) {
throw new Error(`No encounters for tier ${tier}`);
}
return pool[Math.floor(rng() * pool.length)];
}
export function pickFlavor(
encounter: LibraryEncounter,
ctx: FlavorContext,
rng: () => number
): string {
const pool = ctx.success ? encounter.flavorSuccess : encounter.flavorFailure;
return pool[Math.floor(rng() * pool.length)];
} }

View File

@@ -0,0 +1,165 @@
{
"version": "1.0.0",
"encounters": [
{
"key": "generator_repair",
"baseProbability": 0.55,
"tags": ["generator", "objectives"],
"tier": 1,
"flavorSuccess": [
"The generator sputters to life. Light floods the area.",
"Sparks fly, then hum — the generator catches.",
"Wires connected. The machine breathes again."
],
"flavorFailure": [
"The generator kicks back. Too many watchers in the dark.",
"The mechanism jams. Footsteps echo nearby.",
"A noise betrays the position. The generator goes cold."
]
},
{
"key": "totem_cleanse",
"baseProbability": 0.50,
"tags": ["totem", "altruistic", "objectives"],
"tier": 1,
"flavorSuccess": [
"The totem crumbles. Its curse lifts from the fog.",
"Bones scatter. The hex dissolves into smoke.",
"The ritual unravels. Something distant screams."
],
"flavorFailure": [
"The totem resists. Its pull is stronger than expected.",
"A presence drives the survivor back before the cleanse completes.",
"The hex holds. Dread thickens the air."
]
},
{
"key": "chest_search",
"baseProbability": 0.45,
"tags": ["chest", "item"],
"tier": 1,
"flavorSuccess": [
"The chest yields a worn medkit. Small mercies.",
"A flashlight, still charged. The fog recedes slightly.",
"A useful tool among the debris."
],
"flavorFailure": [
"The chest is empty. Only rust and regret.",
"The lid splinters — nothing useful inside.",
"A noise nearby cuts the search short."
]
},
{
"key": "hook_escape",
"baseProbability": 0.40,
"tags": ["hook", "survival"],
"tier": 2,
"flavorSuccess": [
"With grim determination, the survivor slips free.",
"Arms aching, the hook releases. Freedom, for now.",
"A desperate push — the chains give way."
],
"flavorFailure": [
"The struggle exhausts. The hook holds.",
"Every movement drives the barb deeper. Stay still.",
"The fog presses in. The hook remains."
]
},
{
"key": "exit_gate",
"baseProbability": 0.50,
"tags": ["exit", "objectives"],
"tier": 2,
"flavorSuccess": [
"The gate grinds open. Cold air rushes in.",
"Generators humming, the lock gives. Almost there.",
"The exit yields. Light from outside cuts the fog."
],
"flavorFailure": [
"The gate mechanism is jammed. Precious seconds lost.",
"A shadow falls across the panel. The attempt abandoned.",
"The switch is stuck. The gate stays shut."
]
},
{
"key": "patrol_avoid",
"baseProbability": 0.60,
"tags": ["stealth", "survival"],
"tier": 1,
"flavorSuccess": [
"Still as stone. The threat passes without noticing.",
"A breath held long — then silence. Safe.",
"The fog swallows the survivor whole. Unseen."
],
"flavorFailure": [
"A twig snaps. Eye contact — then the chase begins.",
"The survivor misjudges the angle. Spotted.",
"Heartbeat too loud. Presence too close."
]
},
{
"key": "medkit_use",
"baseProbability": 0.65,
"tags": ["healing", "altruistic", "survival"],
"tier": 1,
"flavorSuccess": [
"Bandages tight, the wound closes. Pain recedes.",
"The medkit does its job. The survivor steadies.",
"A few tense minutes — injuries tended."
],
"flavorFailure": [
"Supplies exhausted before the job is done.",
"Shaking hands fumble the medkit. Time runs out.",
"The wound is worse than it looked. Supplies fall short."
]
},
{
"key": "pallet_drop",
"baseProbability": 0.55,
"tags": ["survival", "escape"],
"tier": 2,
"flavorSuccess": [
"The pallet crashes down. A moment bought.",
"Timber splinters between them. Distance gained.",
"The drop lands true. The chase falters."
],
"flavorFailure": [
"The pallet drops wide. No gap created.",
"Too slow — the obstacle proves useless.",
"The throw miscalculated. The pursuit continues."
]
},
{
"key": "basement_search",
"baseProbability": 0.35,
"tags": ["chest", "item", "high-risk"],
"tier": 3,
"flavorSuccess": [
"The basement yields rare supplies. Worth the risk.",
"A pristine toolbox — almost worth dying for.",
"The gamble paid off. The survivor emerges with something valuable."
],
"flavorFailure": [
"The basement was a trap. Retreat costs dearly.",
"The stairwell offers no escape. A mistake made clear.",
"The risk was not worth the reward found — nothing."
]
},
{
"key": "hatch_find",
"baseProbability": 0.30,
"tags": ["exit", "survival", "high-risk"],
"tier": 3,
"flavorSuccess": [
"The hatch sighs open. One last mercy from the fog.",
"A sound — familiar, haunting. The hatch, just ahead.",
"Against all odds, the escape route reveals itself."
],
"flavorFailure": [
"The hatch is nowhere. Only fog and silence.",
"Close — so close. Then it closes.",
"The sound was something else entirely."
]
}
]
}

View File

@@ -3,7 +3,8 @@
"compilerOptions": { "compilerOptions": {
"outDir": "../../dist/out-tsc", "outDir": "../../dist/out-tsc",
"declaration": true, "declaration": true,
"types": ["node"] "types": ["node"],
"resolveJsonModule": true
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"exclude": [ "exclude": [

View File

@@ -2,6 +2,7 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "../../dist/out-tsc", "outDir": "../../dist/out-tsc",
"resolveJsonModule": true,
"types": [ "types": [
"vitest/globals", "vitest/globals",
"vitest/importMeta", "vitest/importMeta",

View File

@@ -11,6 +11,7 @@ export default [
ignoredFiles: [ ignoredFiles: [
'{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
'{projectRoot}/vite.config.{js,ts,mjs,mts}', '{projectRoot}/vite.config.{js,ts,mjs,mts}',
'{projectRoot}/vitest.config.{js,ts,mjs,mts}',
], ],
}, },
], ],

View File

@@ -7,6 +7,10 @@
"types": "./src/index.d.ts", "types": "./src/index.d.ts",
"dependencies": { "dependencies": {
"tslib": "^2.3.0", "tslib": "^2.3.0",
"seedrandom": "^3.0.5",
"@fog-explorer/api-interfaces": "*"
},
"devDependencies": {
"vitest": "^4.0.8", "vitest": "^4.0.8",
"@nx/vite": "^22.7.1" "@nx/vite": "^22.7.1"
} }

View File

@@ -1 +1 @@
export * from './lib/mission-logic'; export * from './lib/encounter-resolver';

View File

@@ -0,0 +1,399 @@
import * as fc from 'fast-check';
import { resolveEncounter, type ResolverInput } from './encounter-resolver';
import type { EncounterDefinition, Perk } from '@fog-explorer/api-interfaces';
// ── Arbitraries ──────────────────────────────────────────────────────────────
const arbSeed = fc.string({ minLength: 8, maxLength: 16 });
const arbEncounter = (tags: string[] = []): fc.Arbitrary<EncounterDefinition> =>
fc.record({
key: fc.constantFrom('generator', 'totem', 'chest', 'hook', 'exit-gate'),
baseProbability: fc.float({ min: 0, max: 1, noNaN: true }),
tags: fc.constant(tags),
});
const arbStats = fc.record({
objectives: fc.integer({ min: 1, max: 10 }),
survival: fc.integer({ min: 1, max: 10 }),
altruism: fc.integer({ min: 1, max: 10 }),
});
const arbInput = (overrides: Partial<ResolverInput> = {}): fc.Arbitrary<ResolverInput> =>
fc.record({
seed: arbSeed,
missionId: fc.uuid(),
tickIndex: fc.nat({ max: 999 }),
difficulty: fc.integer({ min: 1, max: 3 }),
encounter: arbEncounter(),
survivor: fc.record({
id: fc.uuid(),
state: fc.constantFrom('active' as const, 'injured' as const),
stats: arbStats,
perkSlots: fc.constant<Perk[]>([]),
hookCount: fc.integer({ min: 0, max: 2 }),
}),
}).map((base) => ({ ...base, ...overrides }));
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeInput(partial: Partial<ResolverInput> = {}): ResolverInput {
return {
seed: 'fixed-seed',
missionId: '00000000-0000-4000-a000-000000000001',
tickIndex: 0,
difficulty: 1,
encounter: { key: 'generator', baseProbability: 0.5, tags: [] },
survivor: {
id: '00000000-0000-4000-a000-000000000002',
state: 'active',
stats: { objectives: 5, survival: 5, altruism: 5 },
perkSlots: [],
hookCount: 0,
},
...partial,
};
}
// ── Unit tests ────────────────────────────────────────────────────────────────
describe('resolveEncounter', () => {
it('is deterministic: same seed always produces same result', () => {
const input = makeInput();
const a = resolveEncounter(input);
const b = resolveEncounter(input);
expect(a).toEqual(b);
});
it('produces different results for different seeds', () => {
const results = new Set(
Array.from({ length: 20 }, (_, i) =>
resolveEncounter(makeInput({ seed: `seed-${i}` })).success
)
);
// With 20 different seeds at p≈0.6 both true and false should appear
expect(results.size).toBeGreaterThan(1);
});
it('state change is null on success', () => {
// Force a near-certain success with baseProbability=1
const result = resolveEncounter(makeInput({ encounter: { key: 'generator', baseProbability: 1, tags: [] } }));
expect(result.success).toBe(true);
expect(result.survivorStateChange).toBeNull();
});
it('injured survivor transitions to downed on failure', () => {
// Force failure: baseProbability=0
const result = resolveEncounter(
makeInput({
encounter: { key: 'hook', baseProbability: 0, tags: [] },
survivor: {
id: '00000000-0000-4000-a000-000000000002',
state: 'injured',
stats: { objectives: 1, survival: 5, altruism: 1 },
perkSlots: [],
hookCount: 1,
},
})
);
expect(result.success).toBe(false);
expect(result.survivorStateChange).toEqual({ from: 'injured', to: 'downed' });
});
it('downed survivor at hookCount 2 transitions to sacrificed on failure', () => {
const result = resolveEncounter(
makeInput({
encounter: { key: 'hook', baseProbability: 0, tags: [] },
survivor: {
id: '00000000-0000-4000-a000-000000000002',
state: 'downed',
stats: { objectives: 1, survival: 1, altruism: 1 },
perkSlots: [],
hookCount: 2,
},
})
);
expect(result.success).toBe(false);
expect(result.survivorStateChange).toEqual({ from: 'downed', to: 'sacrificed' });
});
it('downed survivor below hookCount 2 has no state change on failure', () => {
const result = resolveEncounter(
makeInput({
encounter: { key: 'hook', baseProbability: 0, tags: [] },
survivor: {
id: '00000000-0000-4000-a000-000000000002',
state: 'downed',
stats: { objectives: 1, survival: 5, altruism: 1 },
perkSlots: [],
hookCount: 1,
},
})
);
expect(result.success).toBe(false);
expect(result.survivorStateChange).toBeNull();
});
it('perk modifier with matching tag applies to success chance', () => {
const perk: Perk = {
id: '00000000-0000-4000-a000-000000000003',
key: 'adrenaline',
name: 'Adrenaline',
description: '',
tags: [],
modifiers: [
{ target: 'successChance', type: 'additive', amount: 0.5, condition: { encounterTags: ['generator'] } },
],
};
// With p=0 base but +0.5 from perk on matching tag → p=0.5 → some successes
const results = Array.from({ length: 10 }, (_, i) =>
resolveEncounter(
makeInput({
seed: `perk-test-${i}`,
encounter: { key: 'gen', baseProbability: 0, tags: ['generator'] },
survivor: {
id: '00000000-0000-4000-a000-000000000002',
state: 'active',
stats: { objectives: 1, survival: 5, altruism: 1 },
perkSlots: [perk],
hookCount: 0,
},
})
).success
);
expect(results.some(Boolean)).toBe(true);
});
it('perk modifier without matching tag does not apply', () => {
const perk: Perk = {
id: '00000000-0000-4000-a000-000000000003',
key: 'irrelevant-perk',
name: 'Irrelevant',
description: '',
tags: [],
modifiers: [
{ target: 'successChance', type: 'additive', amount: 0.99, condition: { encounterTags: ['totem'] } },
],
};
// p=0, perk requires 'totem' but encounter has 'generator' → still fails
const result = resolveEncounter(
makeInput({
encounter: { key: 'gen', baseProbability: 0, tags: ['generator'] },
survivor: {
id: '00000000-0000-4000-a000-000000000002',
state: 'active',
stats: { objectives: 1, survival: 5, altruism: 1 },
perkSlots: [perk],
hookCount: 0,
},
})
);
expect(result.success).toBe(false);
expect(result.modifiersApplied).toHaveLength(0);
});
it('logText is non-empty for all outcomes', () => {
for (let i = 0; i < 20; i++) {
const result = resolveEncounter(makeInput({ seed: `log-test-${i}` }));
expect(result.logText.length).toBeGreaterThan(0);
}
});
});
// ── Property-based tests ──────────────────────────────────────────────────────
describe('resolveEncounter — property-based', () => {
it('result fields match input identifiers', () => {
fc.assert(
fc.property(arbInput(), (input) => {
const result = resolveEncounter(input);
expect(result.missionId).toBe(input.missionId);
expect(result.survivorId).toBe(input.survivor.id);
expect(result.encounterKey).toBe(input.encounter.key);
expect(result.tickIndex).toBe(input.tickIndex);
expect(result.seed).toBe(input.seed);
})
);
});
it('state change is always null when encounter succeeds', () => {
fc.assert(
fc.property(arbInput(), (input) => {
const result = resolveEncounter(input);
if (result.success) {
expect(result.survivorStateChange).toBeNull();
}
})
);
});
it('state change from field always matches survivor current state', () => {
fc.assert(
fc.property(arbInput(), (input) => {
const result = resolveEncounter(input);
if (result.survivorStateChange) {
expect(result.survivorStateChange.from).toBe(input.survivor.state);
}
})
);
});
it('logText is always a non-empty string', () => {
fc.assert(
fc.property(arbInput(), (input) => {
const result = resolveEncounter(input);
expect(typeof result.logText).toBe('string');
expect(result.logText.length).toBeGreaterThan(0);
})
);
});
it('higher difficulty never increases success rate (monte carlo, large sample)', () => {
fc.assert(
fc.property(
arbSeed,
arbEncounter(),
arbStats,
(seed, encounter, stats) => {
const wins = (difficulty: number) =>
Array.from({ length: 40 }, (_, i) =>
resolveEncounter(
makeInput({ seed: `${seed}-${i}`, difficulty, encounter, survivor: {
id: '00000000-0000-4000-a000-000000000002',
state: 'active',
stats,
perkSlots: [],
hookCount: 0,
} })
).success
).filter(Boolean).length;
const easy = wins(1);
const hard = wins(3);
// Over 40 trials, difficulty 3 should not outperform difficulty 1 by more than 10 wins
expect(hard - easy).toBeLessThanOrEqual(10);
}
),
{ numRuns: 50 }
);
});
it('all modifiersApplied entries reference perks in the input perkSlots', () => {
const arbPerk: fc.Arbitrary<Perk> = fc.record({
id: fc.uuid(),
key: fc.string({ minLength: 1 }),
name: fc.string({ minLength: 1 }),
description: fc.string(),
tags: fc.array(fc.string()),
modifiers: fc.array(
fc.record({
target: fc.constantFrom('successChance' as const),
type: fc.constantFrom('additive' as const, 'multiplicative' as const),
amount: fc.float({ min: Math.fround(-0.3), max: Math.fround(0.3), noNaN: true }),
condition: fc.option(
fc.record({ encounterTags: fc.array(fc.constantFrom('generator', 'totem'), { minLength: 1 }) }),
{ nil: undefined }
),
}),
{ maxLength: 3 }
),
});
fc.assert(
fc.property(
arbInput().chain((base) =>
fc.array(arbPerk, { maxLength: 4 }).map((perks) => ({
...base,
survivor: { ...base.survivor, perkSlots: perks },
}))
),
(input) => {
const result = resolveEncounter(input);
const perkKeys = new Set(input.survivor.perkSlots.map((p) => p.key));
for (const mod of result.modifiersApplied) {
expect(perkKeys.has(mod.perkKey)).toBe(true);
}
}
)
);
});
});
// ── Monte Carlo balance snapshot ─────────────────────────────────────────────
describe('encounter resolver — Monte Carlo balance snapshot', () => {
const RUNS = 2000;
function winRate(overrides: Partial<ResolverInput>): number {
return (
Array.from({ length: RUNS }, (_, i) =>
resolveEncounter(makeInput({ seed: `mc-${i}`, ...overrides })).success
).filter(Boolean).length / RUNS
);
}
it('difficulty 1, objectives 5, p=0.5 → win rate 5575%', () => {
const rate = winRate({ difficulty: 1, encounter: { key: 'gen', baseProbability: 0.5, tags: [] } });
// objectives 5 adds 0.10, so effective p ≈ 0.6
expect(rate).toBeGreaterThanOrEqual(0.55);
expect(rate).toBeLessThanOrEqual(0.75);
});
it('difficulty 3, objectives 5, p=0.5 → win rate 3055%', () => {
const rate = winRate({ difficulty: 3, encounter: { key: 'gen', baseProbability: 0.5, tags: [] } });
// objectives 5 adds 0.10, difficulty penalty -0.24 → effective p ≈ 0.36
expect(rate).toBeGreaterThanOrEqual(0.30);
expect(rate).toBeLessThanOrEqual(0.55);
});
it('near-impossible encounter (p=0, min stats) win rate stays below 5%', () => {
// With baseProbability=0 and objectives=1: effective p = 0 + 1*0.02 = 0.02
const rate = winRate({ encounter: { key: 'impossible', baseProbability: 0, tags: [] },
survivor: { id: '00000000-0000-4000-a000-000000000002', state: 'active',
stats: { objectives: 1, survival: 5, altruism: 1 }, perkSlots: [], hookCount: 0 },
});
expect(rate).toBeLessThan(0.05);
});
it('certain encounter (p=1) always succeeds', () => {
const rate = winRate({ encounter: { key: 'certain', baseProbability: 1, tags: [] },
survivor: { id: '00000000-0000-4000-a000-000000000002', state: 'active',
stats: { objectives: 1, survival: 1, altruism: 1 }, perkSlots: [], hookCount: 0 },
difficulty: 3,
});
// Even at difficulty 3 with min stats, effective p ≥ 0 — but p=1 with difficulty penalty:
// 1 - 0.24 + 0.02 = 0.78, so not always 100%. Test that it's at least 70%.
expect(rate).toBeGreaterThanOrEqual(0.70);
});
it('injury rate decreases as survival stat increases', () => {
function injuryRate(survivalStat: number): number {
let injuries = 0;
for (let i = 0; i < RUNS; i++) {
const result = resolveEncounter(
makeInput({
seed: `inj-${i}`,
encounter: { key: 'hook', baseProbability: 0, tags: [] },
survivor: {
id: '00000000-0000-4000-a000-000000000002',
state: 'active',
stats: { objectives: 1, survival: survivalStat, altruism: 1 },
perkSlots: [],
hookCount: 0,
},
})
);
if (result.survivorStateChange?.to === 'injured') injuries++;
}
return injuries / RUNS;
}
const lowSurvival = injuryRate(1);
const highSurvival = injuryRate(10);
expect(highSurvival).toBeLessThan(lowSurvival);
// At survival=10 the injury floor is 0.3, so rate should be around 0.3
expect(highSurvival).toBeLessThanOrEqual(0.40);
// At survival=1 the injury rate should be around 0.75
expect(lowSurvival).toBeGreaterThanOrEqual(0.65);
});
});

View File

@@ -0,0 +1,156 @@
import seedrandom = require('seedrandom');
import type {
EncounterDefinition,
EncounterResult,
ModifierApplication,
Perk,
SurvivorState,
SurvivorStats,
} from '@fog-explorer/api-interfaces';
export interface ResolverInput {
seed: string;
missionId: string;
tickIndex: number;
difficulty: number;
encounter: EncounterDefinition;
survivor: {
id: string;
state: SurvivorState;
stats: SurvivorStats;
perkSlots: Perk[];
hookCount: number;
};
}
// Probability adjustments per stat point and per difficulty level.
const OBJECTIVES_BONUS = 0.02;
const ALTRUISM_BONUS = 0.02;
const DIFFICULTY_PENALTY = 0.12;
// Tag that enables the altruism stat bonus.
const ALTRUISM_TAG = 'altruistic';
// Injury probability floor/ceiling when active survivor fails.
const INJURY_CHANCE_BASE = 0.8;
const INJURY_CHANCE_FLOOR = 0.2;
const SURVIVAL_INJURY_REDUCTION = 0.05;
export function resolveEncounter(input: ResolverInput): EncounterResult {
const rng = seedrandom(input.seed);
let probability = input.encounter.baseProbability;
probability -= (input.difficulty - 1) * DIFFICULTY_PENALTY;
probability += input.survivor.stats.objectives * OBJECTIVES_BONUS;
if (input.encounter.tags.includes(ALTRUISM_TAG)) {
probability += input.survivor.stats.altruism * ALTRUISM_BONUS;
}
const modifiersApplied = applyPerkModifiers(probability, input);
probability = modifiersApplied.adjusted;
const appliedList = modifiersApplied.applied;
probability = Math.max(0, Math.min(1, probability));
const success = rng() < probability;
const survivorStateChange = success
? null
: computeStateChange(rng, input.survivor.state, input.survivor.stats.survival, input.survivor.hookCount);
return {
missionId: input.missionId,
survivorId: input.survivor.id,
encounterKey: input.encounter.key,
tickIndex: input.tickIndex,
seed: input.seed,
success,
survivorStateChange,
modifiersApplied: appliedList,
logText: buildLogText(input.encounter.key, success, survivorStateChange),
};
}
function applyPerkModifiers(
startProbability: number,
input: ResolverInput
): { adjusted: number; applied: ModifierApplication[] } {
let probability = startProbability;
const applied: ModifierApplication[] = [];
for (const perk of input.survivor.perkSlots) {
for (const mod of perk.modifiers) {
if (mod.target !== 'successChance') continue;
if (mod.condition) {
const tagMatch = mod.condition.encounterTags.some((t) =>
input.encounter.tags.includes(t)
);
if (!tagMatch) continue;
}
if (mod.type === 'additive') {
probability += mod.amount;
} else {
probability *= 1 + mod.amount;
}
applied.push({
perkKey: perk.key,
target: mod.target,
type: mod.type,
effectiveAmount: mod.amount,
});
}
}
return { adjusted: probability, applied };
}
function computeStateChange(
rng: seedrandom.PRNG,
currentState: SurvivorState,
survivalStat: number,
hookCount: number
): EncounterResult['survivorStateChange'] {
if (currentState === 'active') {
const injuryChance = Math.max(
INJURY_CHANCE_FLOOR,
INJURY_CHANCE_BASE - survivalStat * SURVIVAL_INJURY_REDUCTION
);
if (rng() < injuryChance) {
return { from: 'active', to: 'injured' };
}
return null;
}
if (currentState === 'injured') {
return { from: 'injured', to: 'downed' };
}
if (currentState === 'downed' && hookCount >= 2) {
return { from: 'downed', to: 'sacrificed' };
}
return null;
}
const STATE_CHANGE_TEXT: Record<string, string> = {
'active->injured': 'Survivor was injured.',
'injured->downed': 'Survivor was downed.',
'downed->sacrificed': 'Survivor was sacrificed.',
};
function buildLogText(
encounterKey: string,
success: boolean,
stateChange: EncounterResult['survivorStateChange']
): string {
const outcome = success ? 'succeeded' : 'failed';
const base = `${encounterKey}: ${outcome}.`;
if (!stateChange) return base;
const extra = STATE_CHANGE_TEXT[`${stateChange.from}->${stateChange.to}`];
return extra ? `${base} ${extra}` : base;
}

View File

@@ -1,7 +0,0 @@
import { missionLogic } from './mission-logic';
describe('missionLogic', () => {
it('should work', () => {
expect(missionLogic()).toEqual('mission-logic');
});
});

View File

@@ -1,3 +0,0 @@
export function missionLogic(): string {
return 'mission-logic';
}

View File

@@ -50,7 +50,6 @@
"plugin": "@nx/vitest", "plugin": "@nx/vitest",
"options": { "options": {
"testTargetName": "test", "testTargetName": "test",
"ciTargetName": "test-ci",
"testMode": "watch" "testMode": "watch"
} }
} }

View File

@@ -36,12 +36,15 @@
"@swc/helpers": "~0.5.18", "@swc/helpers": "~0.5.18",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "20.19.9", "@types/node": "20.19.9",
"@types/seedrandom": "^3.0.8",
"@typescript-eslint/utils": "^8.40.0", "@typescript-eslint/utils": "^8.40.0",
"@vitest/coverage-v8": "~4.1.0", "@vitest/coverage-v8": "~4.1.0",
"angular-eslint": "^21.2.0", "angular-eslint": "^21.2.0",
"eslint": "^9.8.0", "eslint": "^9.8.0",
"eslint-config-prettier": "^10.0.0", "eslint-config-prettier": "^10.0.0",
"eslint-plugin-playwright": "^1.6.2", "eslint-plugin-playwright": "^1.6.2",
"fast-check": "^4.7.0",
"happy-dom": "^20.9.0",
"jest": "^30.0.2", "jest": "^30.0.2",
"jest-environment-node": "^30.0.2", "jest-environment-node": "^30.0.2",
"jest-util": "^30.0.2", "jest-util": "^30.0.2",
@@ -68,9 +71,12 @@
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",
"@nestjs/schedule": "^6.1.3",
"axios": "^1.6.0", "axios": "^1.6.0",
"ioredis": "^5.10.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"seedrandom": "^3.0.5",
"zod": "^4.4.3" "zod": "^4.4.3"
} }
} }

171
pnpm-lock.yaml generated
View File

@@ -35,15 +35,24 @@ importers:
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^11.0.0 specifier: ^11.0.0
version: 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.19) version: 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.19)
'@nestjs/schedule':
specifier: ^6.1.3
version: 6.1.3(@nestjs/common@11.1.19(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.19)
axios: axios:
specifier: ^1.6.0 specifier: ^1.6.0
version: 1.15.0 version: 1.15.0
ioredis:
specifier: ^5.10.1
version: 5.10.1
reflect-metadata: reflect-metadata:
specifier: ^0.1.13 specifier: ^0.1.13
version: 0.1.14 version: 0.1.14
rxjs: rxjs:
specifier: ^7.8.0 specifier: ^7.8.0
version: 7.8.2 version: 7.8.2
seedrandom:
specifier: ^3.0.5
version: 3.0.5
zod: zod:
specifier: ^4.4.3 specifier: ^4.4.3
version: 4.4.3 version: 4.4.3
@@ -141,6 +150,9 @@ importers:
'@types/node': '@types/node':
specifier: 20.19.9 specifier: 20.19.9
version: 20.19.9 version: 20.19.9
'@types/seedrandom':
specifier: ^3.0.8
version: 3.0.8
'@typescript-eslint/utils': '@typescript-eslint/utils':
specifier: ^8.40.0 specifier: ^8.40.0
version: 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) version: 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
@@ -159,6 +171,12 @@ importers:
eslint-plugin-playwright: eslint-plugin-playwright:
specifier: ^1.6.2 specifier: ^1.6.2
version: 1.8.3(eslint@9.39.4(jiti@2.7.0)) version: 1.8.3(eslint@9.39.4(jiti@2.7.0))
fast-check:
specifier: ^4.7.0
version: 4.7.0
happy-dom:
specifier: ^20.9.0
version: 20.9.0
jest: jest:
specifier: ^30.0.2 specifier: ^30.0.2
version: 30.3.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@20.19.9)(typescript@5.9.3)) version: 30.3.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@20.19.9)(typescript@5.9.3))
@@ -200,7 +218,7 @@ importers:
version: 8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0) version: 8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0)
vitest: vitest:
specifier: ^4.0.8 specifier: ^4.0.8
version: 4.1.5(@types/node@20.19.9)(@vitest/coverage-v8@4.1.5)(jsdom@27.4.0)(vite@8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0)) version: 4.1.5(@types/node@20.19.9)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0))
webpack-cli: webpack-cli:
specifier: ^5.1.4 specifier: ^5.1.4
version: 5.1.4(webpack@5.106.2) version: 5.1.4(webpack@5.106.2)
@@ -1541,6 +1559,9 @@ packages:
'@types/node': '@types/node':
optional: true optional: true
'@ioredis/commands@1.5.1':
resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==}
'@isaacs/cliui@8.0.2': '@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -2139,6 +2160,12 @@ packages:
'@nestjs/common': ^11.0.0 '@nestjs/common': ^11.0.0
'@nestjs/core': ^11.0.0 '@nestjs/core': ^11.0.0
'@nestjs/schedule@6.1.3':
resolution: {integrity: sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
'@nestjs/schematics@11.1.0': '@nestjs/schematics@11.1.0':
resolution: {integrity: sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==} resolution: {integrity: sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==}
peerDependencies: peerDependencies:
@@ -3261,6 +3288,9 @@ packages:
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/luxon@3.7.1':
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
'@types/mime@1.3.5': '@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
@@ -3282,6 +3312,9 @@ packages:
'@types/retry@0.12.2': '@types/retry@0.12.2':
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
'@types/seedrandom@3.0.8':
resolution: {integrity: sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==}
'@types/semver@7.5.8': '@types/semver@7.5.8':
resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
@@ -3303,6 +3336,9 @@ packages:
'@types/stack-utils@2.0.3': '@types/stack-utils@2.0.3':
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
'@types/ws@8.18.1': '@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -4068,6 +4104,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
cluster-key-slot@1.1.2:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
co@4.6.0: co@4.6.0:
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
@@ -4221,6 +4261,10 @@ packages:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
cron@4.4.0:
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
engines: {node: '>=18.x'}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -4392,6 +4436,10 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
depd@1.1.2: depd@1.1.2:
resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -4732,6 +4780,10 @@ packages:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
fast-check@4.7.0:
resolution: {integrity: sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==}
engines: {node: '>=12.17.0'}
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -4991,6 +5043,10 @@ packages:
engines: {node: '>=0.4.7'} engines: {node: '>=0.4.7'}
hasBin: true hasBin: true
happy-dom@20.9.0:
resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==}
engines: {node: '>=20.0.0'}
harmony-reflect@1.6.2: harmony-reflect@1.6.2:
resolution: {integrity: sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==} resolution: {integrity: sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==}
@@ -5178,6 +5234,10 @@ packages:
resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
ioredis@5.10.1:
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
engines: {node: '>=12.22.0'}
ip-address@10.2.0: ip-address@10.2.0:
resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
@@ -5733,6 +5793,12 @@ packages:
lodash.debounce@4.0.8: lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
lodash.memoize@4.1.2: lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
@@ -6600,6 +6666,9 @@ packages:
pure-rand@7.0.1: pure-rand@7.0.1:
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
pure-rand@8.4.0:
resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==}
pvtsutils@1.3.6: pvtsutils@1.3.6:
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
@@ -6660,6 +6729,14 @@ packages:
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
engines: {node: '>= 10.13.0'} engines: {node: '>= 10.13.0'}
redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
redis-parser@3.0.0:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
reflect-metadata@0.1.14: reflect-metadata@0.1.14:
resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==} resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==}
@@ -6944,6 +7021,9 @@ packages:
secure-compare@3.0.1: secure-compare@3.0.1:
resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==} resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==}
seedrandom@3.0.5:
resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==}
select-hose@2.0.0: select-hose@2.0.0:
resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==} resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==}
@@ -7147,6 +7227,9 @@ packages:
stackframe@1.3.4: stackframe@1.3.4:
resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
statuses@1.5.0: statuses@1.5.0:
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -7854,6 +7937,10 @@ packages:
engines: {node: '>=12'} engines: {node: '>=12'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-mimetype@3.0.0:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
engines: {node: '>=12'}
whatwg-mimetype@4.0.0: whatwg-mimetype@4.0.0:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -8268,7 +8355,7 @@ snapshots:
less: 4.5.1 less: 4.5.1
lmdb: 3.5.1 lmdb: 3.5.1
postcss: 8.5.14 postcss: 8.5.14
vitest: 4.1.5(@types/node@20.19.9)(@vitest/coverage-v8@4.1.5)(jsdom@27.4.0)(vite@8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0)) vitest: 4.1.5(@types/node@20.19.9)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0))
transitivePeerDependencies: transitivePeerDependencies:
- '@emnapi/core' - '@emnapi/core'
- '@emnapi/runtime' - '@emnapi/runtime'
@@ -9526,6 +9613,8 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/node': 20.19.9 '@types/node': 20.19.9
'@ioredis/commands@1.5.1': {}
'@isaacs/cliui@8.0.2': '@isaacs/cliui@8.0.2':
dependencies: dependencies:
string-width: 5.1.2 string-width: 5.1.2
@@ -10282,6 +10371,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@nestjs/schedule@6.1.3(@nestjs/common@11.1.19(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.19)':
dependencies:
'@nestjs/common': 11.1.19(reflect-metadata@0.1.14)(rxjs@7.8.2)
'@nestjs/core': 11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@11.1.19)(reflect-metadata@0.1.14)(rxjs@7.8.2)
cron: 4.4.0
'@nestjs/schematics@11.1.0(prettier@3.6.2)(typescript@5.9.3)': '@nestjs/schematics@11.1.0(prettier@3.6.2)(typescript@5.9.3)':
dependencies: dependencies:
'@angular-devkit/core': 19.2.24 '@angular-devkit/core': 19.2.24
@@ -10781,7 +10876,7 @@ snapshots:
tsconfig-paths: 4.2.0 tsconfig-paths: 4.2.0
tslib: 2.8.1 tslib: 2.8.1
vite: 8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0) vite: 8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0)
vitest: 4.1.5(@types/node@20.19.9)(@vitest/coverage-v8@4.1.5)(jsdom@27.4.0)(vite@8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0)) vitest: 4.1.5(@types/node@20.19.9)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0))
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/traverse' - '@babel/traverse'
- '@nx/eslint' - '@nx/eslint'
@@ -10803,7 +10898,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@nx/eslint': 22.7.1(aae4ffcd5669f069990aaa10ad143f8a) '@nx/eslint': 22.7.1(aae4ffcd5669f069990aaa10ad143f8a)
vite: 8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0) vite: 8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0)
vitest: 4.1.5(@types/node@20.19.9)(@vitest/coverage-v8@4.1.5)(jsdom@27.4.0)(vite@8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0)) vitest: 4.1.5(@types/node@20.19.9)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0))
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/traverse' - '@babel/traverse'
- '@swc-node/register' - '@swc-node/register'
@@ -11704,6 +11799,8 @@ snapshots:
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/luxon@3.7.1': {}
'@types/mime@1.3.5': {} '@types/mime@1.3.5': {}
'@types/node-forge@1.3.14': '@types/node-forge@1.3.14':
@@ -11722,6 +11819,8 @@ snapshots:
'@types/retry@0.12.2': {} '@types/retry@0.12.2': {}
'@types/seedrandom@3.0.8': {}
'@types/semver@7.5.8': {} '@types/semver@7.5.8': {}
'@types/send@0.17.6': '@types/send@0.17.6':
@@ -11749,6 +11848,8 @@ snapshots:
'@types/stack-utils@2.0.3': {} '@types/stack-utils@2.0.3': {}
'@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.1': '@types/ws@8.18.1':
dependencies: dependencies:
'@types/node': 20.19.9 '@types/node': 20.19.9
@@ -11927,7 +12028,7 @@ snapshots:
obug: 2.1.1 obug: 2.1.1
std-env: 4.1.0 std-env: 4.1.0
tinyrainbow: 3.1.0 tinyrainbow: 3.1.0
vitest: 4.1.5(@types/node@20.19.9)(@vitest/coverage-v8@4.1.5)(jsdom@27.4.0)(vite@8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0)) vitest: 4.1.5(@types/node@20.19.9)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0))
'@vitest/expect@4.1.5': '@vitest/expect@4.1.5':
dependencies: dependencies:
@@ -12624,6 +12725,8 @@ snapshots:
clone@1.0.4: {} clone@1.0.4: {}
cluster-key-slot@1.1.2: {}
co@4.6.0: {} co@4.6.0: {}
collect-v8-coverage@1.0.3: {} collect-v8-coverage@1.0.3: {}
@@ -12767,6 +12870,11 @@ snapshots:
dependencies: dependencies:
luxon: 3.7.2 luxon: 3.7.2
cron@4.4.0:
dependencies:
'@types/luxon': 3.7.1
luxon: 3.7.2
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -12935,6 +13043,8 @@ snapshots:
delayed-stream@1.0.0: {} delayed-stream@1.0.0: {}
denque@2.1.0: {}
depd@1.1.2: {} depd@1.1.2: {}
depd@2.0.0: {} depd@2.0.0: {}
@@ -13336,6 +13446,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
fast-check@4.7.0:
dependencies:
pure-rand: 8.4.0
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-json-stable-stringify@2.1.0: {} fast-json-stable-stringify@2.1.0: {}
@@ -13612,6 +13726,18 @@ snapshots:
optionalDependencies: optionalDependencies:
uglify-js: 3.19.3 uglify-js: 3.19.3
happy-dom@20.9.0:
dependencies:
'@types/node': 20.19.9
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
entities: 7.0.1
whatwg-mimetype: 3.0.0
ws: 8.20.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
harmony-reflect@1.6.2: {} harmony-reflect@1.6.2: {}
has-flag@4.0.0: {} has-flag@4.0.0: {}
@@ -13818,6 +13944,20 @@ snapshots:
interpret@3.1.1: {} interpret@3.1.1: {}
ioredis@5.10.1:
dependencies:
'@ioredis/commands': 1.5.1
cluster-key-slot: 1.1.2
debug: 4.4.3
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
ip-address@10.2.0: {} ip-address@10.2.0: {}
ip-regex@4.3.0: {} ip-regex@4.3.0: {}
@@ -14512,6 +14652,10 @@ snapshots:
lodash.debounce@4.0.8: {} lodash.debounce@4.0.8: {}
lodash.defaults@4.2.0: {}
lodash.isarguments@3.1.0: {}
lodash.memoize@4.1.2: {} lodash.memoize@4.1.2: {}
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
@@ -15527,6 +15671,8 @@ snapshots:
pure-rand@7.0.1: {} pure-rand@7.0.1: {}
pure-rand@8.4.0: {}
pvtsutils@1.3.6: pvtsutils@1.3.6:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -15593,6 +15739,12 @@ snapshots:
dependencies: dependencies:
resolve: 1.22.12 resolve: 1.22.12
redis-errors@1.2.0: {}
redis-parser@3.0.0:
dependencies:
redis-errors: 1.2.0
reflect-metadata@0.1.14: {} reflect-metadata@0.1.14: {}
reflect-metadata@0.2.2: {} reflect-metadata@0.2.2: {}
@@ -15908,6 +16060,8 @@ snapshots:
secure-compare@3.0.1: {} secure-compare@3.0.1: {}
seedrandom@3.0.5: {}
select-hose@2.0.0: {} select-hose@2.0.0: {}
selfsigned@2.4.1: selfsigned@2.4.1:
@@ -16166,6 +16320,8 @@ snapshots:
stackframe@1.3.4: {} stackframe@1.3.4: {}
standard-as-callback@2.1.0: {}
statuses@1.5.0: {} statuses@1.5.0: {}
statuses@2.0.2: {} statuses@2.0.2: {}
@@ -16652,7 +16808,7 @@ snapshots:
terser: 5.46.2 terser: 5.46.2
yaml: 2.8.0 yaml: 2.8.0
vitest@4.1.5(@types/node@20.19.9)(@vitest/coverage-v8@4.1.5)(jsdom@27.4.0)(vite@8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0)): vitest@4.1.5(@types/node@20.19.9)(@vitest/coverage-v8@4.1.5)(happy-dom@20.9.0)(jsdom@27.4.0)(vite@8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0)):
dependencies: dependencies:
'@vitest/expect': 4.1.5 '@vitest/expect': 4.1.5
'@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0)) '@vitest/mocker': 4.1.5(vite@8.0.10(@types/node@20.19.9)(esbuild@0.27.3)(jiti@2.7.0)(less@4.5.1)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.46.2)(yaml@2.8.0))
@@ -16677,6 +16833,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/node': 20.19.9 '@types/node': 20.19.9
'@vitest/coverage-v8': 4.1.5(vitest@4.1.5) '@vitest/coverage-v8': 4.1.5(vitest@4.1.5)
happy-dom: 20.9.0
jsdom: 27.4.0 jsdom: 27.4.0
transitivePeerDependencies: transitivePeerDependencies:
- msw - msw
@@ -16839,6 +16996,8 @@ snapshots:
dependencies: dependencies:
iconv-lite: 0.6.3 iconv-lite: 0.6.3
whatwg-mimetype@3.0.0: {}
whatwg-mimetype@4.0.0: {} whatwg-mimetype@4.0.0: {}
whatwg-mimetype@5.0.0: {} whatwg-mimetype@5.0.0: {}