From e8523d270e7e19fc716337a48b88c6f54785cb35 Mon Sep 17 00:00:00 2001 From: Maurycy Date: Thu, 7 May 2026 14:25:46 +0000 Subject: [PATCH] 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. --- .../skills/frontend-angular/.skillfish.json | 10 + .agents/skills/frontend-angular/SKILL.md | 190 +++++++++ .agents/skills/frontend-angular/examples.md | 83 ++++ .agents/skills/frontend-angular/reference.md | 174 ++++++++ .claude/settings.json | 14 +- .../skills/frontend-angular/.skillfish.json | 10 + .claude/skills/frontend-angular/SKILL.md | 190 +++++++++ .claude/skills/frontend-angular/examples.md | 83 ++++ .claude/skills/frontend-angular/reference.md | 174 ++++++++ PROJECT_CONTEXT.md | 6 +- apps/api/src/app/app.module.ts | 13 +- apps/api/src/app/auth/twitch-jwt.guard.ts | 72 ++++ .../api/src/app/missions/encounter.service.ts | 171 ++++++++ .../src/app/missions/group-synergy.service.ts | 63 +++ .../src/app/missions/mission-store.service.ts | 87 ++++ .../src/app/missions/missions.controller.ts | 48 +++ apps/api/src/app/missions/missions.module.ts | 20 + apps/api/src/app/missions/missions.service.ts | 75 ++++ apps/api/src/app/redis/redis.module.ts | 20 + .../src/app/tick-engine/tick-engine.module.ts | 9 + apps/api/src/app/tick-engine/tick.service.ts | 61 +++ apps/api/src/main.ts | 2 +- apps/overlay/project.json | 6 +- apps/overlay/src/app/app.config.ts | 9 +- apps/overlay/src/app/app.spec.ts | 20 - apps/overlay/src/app/app.ts | 12 +- .../src/app/ebs/auth.interceptor.spec.ts | 68 +++ apps/overlay/src/app/ebs/auth.interceptor.ts | 9 + .../src/app/ebs/ebs-api.service.spec.ts | 78 ++++ apps/overlay/src/app/ebs/ebs-api.service.ts | 41 ++ .../app/mission/mission-state.store.spec.ts | 159 +++++++ .../src/app/mission/mission-state.store.ts | 58 +++ .../src/app/panel/ambient-event.component.ts | 52 +++ .../src/app/panel/expanded-panel.component.ts | 129 ++++++ .../app/panel/minimised-panel.component.ts | 78 ++++ .../app/panel/panel-shell.component.spec.ts | 253 +++++++++++ .../src/app/panel/panel-shell.component.ts | 177 ++++++++ .../app/twitch/twitch-auth.service.spec.ts | 107 +++++ .../src/app/twitch/twitch-auth.service.ts | 87 ++++ apps/overlay/src/test-setup.ts | 11 + apps/overlay/src/twitch-ext.d.ts | 47 +++ apps/overlay/vitest.config.mts | 21 + docs/adr/0008-angular-for-overlay-frontend.md | 44 ++ docs/adr/README.md | 1 + libs/api-interfaces/package.json | 3 +- libs/api-interfaces/src/index.ts | 2 + .../src/lib/encounter-definition.ts | 8 + libs/api-interfaces/src/lib/mission-state.ts | 23 + libs/encounter-library/eslint.config.mjs | 1 + libs/encounter-library/package.json | 3 + libs/encounter-library/project.json | 9 +- .../src/lib/encounter-library.spec.ts | 65 ++- .../src/lib/encounter-library.ts | 71 +++- .../encounter-library/src/lib/encounters.json | 165 ++++++++ libs/encounter-library/tsconfig.lib.json | 3 +- libs/encounter-library/tsconfig.spec.json | 1 + libs/mission-logic/eslint.config.mjs | 1 + libs/mission-logic/package.json | 4 + libs/mission-logic/src/index.ts | 2 +- .../src/lib/encounter-resolver.spec.ts | 399 ++++++++++++++++++ .../src/lib/encounter-resolver.ts | 156 +++++++ .../src/lib/mission-logic.spec.ts | 7 - libs/mission-logic/src/lib/mission-logic.ts | 3 - nx.json | 1 - package.json | 6 + pnpm-lock.yaml | 171 +++++++- 66 files changed, 4074 insertions(+), 72 deletions(-) create mode 100644 .agents/skills/frontend-angular/.skillfish.json create mode 100644 .agents/skills/frontend-angular/SKILL.md create mode 100644 .agents/skills/frontend-angular/examples.md create mode 100644 .agents/skills/frontend-angular/reference.md create mode 100644 .claude/skills/frontend-angular/.skillfish.json create mode 100644 .claude/skills/frontend-angular/SKILL.md create mode 100644 .claude/skills/frontend-angular/examples.md create mode 100644 .claude/skills/frontend-angular/reference.md create mode 100644 apps/api/src/app/auth/twitch-jwt.guard.ts create mode 100644 apps/api/src/app/missions/encounter.service.ts create mode 100644 apps/api/src/app/missions/group-synergy.service.ts create mode 100644 apps/api/src/app/missions/mission-store.service.ts create mode 100644 apps/api/src/app/missions/missions.controller.ts create mode 100644 apps/api/src/app/missions/missions.module.ts create mode 100644 apps/api/src/app/missions/missions.service.ts create mode 100644 apps/api/src/app/redis/redis.module.ts create mode 100644 apps/api/src/app/tick-engine/tick-engine.module.ts create mode 100644 apps/api/src/app/tick-engine/tick.service.ts delete mode 100644 apps/overlay/src/app/app.spec.ts create mode 100644 apps/overlay/src/app/ebs/auth.interceptor.spec.ts create mode 100644 apps/overlay/src/app/ebs/auth.interceptor.ts create mode 100644 apps/overlay/src/app/ebs/ebs-api.service.spec.ts create mode 100644 apps/overlay/src/app/ebs/ebs-api.service.ts create mode 100644 apps/overlay/src/app/mission/mission-state.store.spec.ts create mode 100644 apps/overlay/src/app/mission/mission-state.store.ts create mode 100644 apps/overlay/src/app/panel/ambient-event.component.ts create mode 100644 apps/overlay/src/app/panel/expanded-panel.component.ts create mode 100644 apps/overlay/src/app/panel/minimised-panel.component.ts create mode 100644 apps/overlay/src/app/panel/panel-shell.component.spec.ts create mode 100644 apps/overlay/src/app/panel/panel-shell.component.ts create mode 100644 apps/overlay/src/app/twitch/twitch-auth.service.spec.ts create mode 100644 apps/overlay/src/app/twitch/twitch-auth.service.ts create mode 100644 apps/overlay/src/test-setup.ts create mode 100644 apps/overlay/src/twitch-ext.d.ts create mode 100644 apps/overlay/vitest.config.mts create mode 100644 docs/adr/0008-angular-for-overlay-frontend.md create mode 100644 libs/api-interfaces/src/lib/encounter-definition.ts create mode 100644 libs/api-interfaces/src/lib/mission-state.ts create mode 100644 libs/encounter-library/src/lib/encounters.json create mode 100644 libs/mission-logic/src/lib/encounter-resolver.spec.ts create mode 100644 libs/mission-logic/src/lib/encounter-resolver.ts delete mode 100644 libs/mission-logic/src/lib/mission-logic.spec.ts delete mode 100644 libs/mission-logic/src/lib/mission-logic.ts diff --git a/.agents/skills/frontend-angular/.skillfish.json b/.agents/skills/frontend-angular/.skillfish.json new file mode 100644 index 0000000..6127193 --- /dev/null +++ b/.agents/skills/frontend-angular/.skillfish.json @@ -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" +} \ No newline at end of file diff --git a/.agents/skills/frontend-angular/SKILL.md b/.agents/skills/frontend-angular/SKILL.md new file mode 100644 index 0000000..cc6b1ae --- /dev/null +++ b/.agents/skills/frontend-angular/SKILL.md @@ -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: ` + + ` +}) +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 + +@if (isLoading()) { + +} @else if (hasError()) { + +} @else { + +} + + +@for (item of items(); track item.id) { + +} @empty { +

No items found

+} + + +@switch (status()) { + @case ('pending') { } + @case ('active') { } + @default { } +} +``` + +## 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(); + + // Input with default + showAvatar = input(true); + + // Output + selected = output(); + + // 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(null); + readonly summary = this._summary.asReadonly(); + + private _loading = signal(false); + readonly loading = this._loading.asReadonly(); + + getSummary() { + this._loading.set(true); + return this.http.get('/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. diff --git a/.agents/skills/frontend-angular/examples.md b/.agents/skills/frontend-angular/examples.md new file mode 100644 index 0000000..d0a30dd --- /dev/null +++ b/.agents/skills/frontend-angular/examples.md @@ -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: ` +
+

Key Metrics

+ + @for (metric of metrics(); track metric.label) { +
+ {{ metric.label }} + {{ formatValue(metric) }} + + @switch (metric.trend) { + @case ('up') { ↑ } + @case ('down') { ↓ } + @default { → } + } + +
+ } +
+ `, + 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(); + + 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(); + } + } +} +``` diff --git a/.agents/skills/frontend-angular/reference.md b/.agents/skills/frontend-angular/reference.md new file mode 100644 index 0000000..a1375af --- /dev/null +++ b/.agents/skills/frontend-angular/reference.md @@ -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: ` +
+ + @if (form.controls.email.errors?.['required']) { + Email is required + } + +
+ ` +}) +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 } +``` diff --git a/.claude/settings.json b/.claude/settings.json index 887af87..db5172b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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": { "nx-claude-plugins": { "source": { @@ -6,8 +17,5 @@ "repo": "nrwl/nx-ai-agents-config" } } - }, - "enabledPlugins": { - "nx@nx-claude-plugins": true } } diff --git a/.claude/skills/frontend-angular/.skillfish.json b/.claude/skills/frontend-angular/.skillfish.json new file mode 100644 index 0000000..6127193 --- /dev/null +++ b/.claude/skills/frontend-angular/.skillfish.json @@ -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" +} \ No newline at end of file diff --git a/.claude/skills/frontend-angular/SKILL.md b/.claude/skills/frontend-angular/SKILL.md new file mode 100644 index 0000000..cc6b1ae --- /dev/null +++ b/.claude/skills/frontend-angular/SKILL.md @@ -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: ` + + ` +}) +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 + +@if (isLoading()) { + +} @else if (hasError()) { + +} @else { + +} + + +@for (item of items(); track item.id) { + +} @empty { +

No items found

+} + + +@switch (status()) { + @case ('pending') { } + @case ('active') { } + @default { } +} +``` + +## 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(); + + // Input with default + showAvatar = input(true); + + // Output + selected = output(); + + // 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(null); + readonly summary = this._summary.asReadonly(); + + private _loading = signal(false); + readonly loading = this._loading.asReadonly(); + + getSummary() { + this._loading.set(true); + return this.http.get('/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. diff --git a/.claude/skills/frontend-angular/examples.md b/.claude/skills/frontend-angular/examples.md new file mode 100644 index 0000000..d0a30dd --- /dev/null +++ b/.claude/skills/frontend-angular/examples.md @@ -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: ` +
+

Key Metrics

+ + @for (metric of metrics(); track metric.label) { +
+ {{ metric.label }} + {{ formatValue(metric) }} + + @switch (metric.trend) { + @case ('up') { ↑ } + @case ('down') { ↓ } + @default { → } + } + +
+ } +
+ `, + 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(); + + 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(); + } + } +} +``` diff --git a/.claude/skills/frontend-angular/reference.md b/.claude/skills/frontend-angular/reference.md new file mode 100644 index 0000000..a1375af --- /dev/null +++ b/.claude/skills/frontend-angular/reference.md @@ -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: ` +
+ + @if (form.controls.email.errors?.['required']) { + Email is required + } + +
+ ` +}) +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 } +``` diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 3fda9a3..7d9fb57 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -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`. - 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. - Root `docker-compose.yml`: Postgres (named `fog_expedition`, healthcheck), Redis, optional API service. - `.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 ` 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. - `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}`. ### 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: -- **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. - **Group/SWF expanded UI:** designed conceptually, not mocked. - **Streamer config screen:** designed conceptually, not mocked. diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 8662803..f6ed546 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { ScheduleModule } from '@nestjs/schedule'; +import { MissionsModule } from './missions/missions.module'; +import { TickEngineModule } from './tick-engine/tick-engine.module'; @Module({ - imports: [], - controllers: [AppController], - providers: [AppService], + imports: [ + ScheduleModule.forRoot(), + MissionsModule, + TickEngineModule, + ], }) export class AppModule {} diff --git a/apps/api/src/app/auth/twitch-jwt.guard.ts b/apps/api/src/app/auth/twitch-jwt.guard.ts new file mode 100644 index 0000000..d9c0b63 --- /dev/null +++ b/apps/api/src/app/auth/twitch-jwt.guard.ts @@ -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; + twitchClaims?: TwitchJwtPayload; +} + +export const TwitchClaims = createParamDecorator( + (_: unknown, ctx: ExecutionContext): TwitchJwtPayload => { + const req = ctx.switchToHttp().getRequest(); + 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(); + 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; +} diff --git a/apps/api/src/app/missions/encounter.service.ts b/apps/api/src/app/missions/encounter.service.ts new file mode 100644 index 0000000..5c631be --- /dev/null +++ b/apps/api/src/app/missions/encounter.service.ts @@ -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 + ): NonNullable { + 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, + }; +} diff --git a/apps/api/src/app/missions/group-synergy.service.ts b/apps/api/src/app/missions/group-synergy.service.ts new file mode 100644 index 0000000..db58d66 --- /dev/null +++ b/apps/api/src/app/missions/group-synergy.service.ts @@ -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(); + 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]; + } +} diff --git a/apps/api/src/app/missions/mission-store.service.ts b/apps/api/src/app/missions/mission-store.service.ts new file mode 100644 index 0000000..68c2567 --- /dev/null +++ b/apps/api/src/app/missions/mission-store.service.ts @@ -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 { + const raw = await this.redis.get(`${ACTIVE_MISSION_PREFIX}${missionId}`); + if (!raw) return null; + return JSON.parse(raw) as MissionStateResponse; + } + + async setActiveMission(state: NonNullable): Promise { + const missionId = state.mission.id; + const key = `${ACTIVE_MISSION_PREFIX}${missionId}`; + await this.redis.set(key, JSON.stringify(state)); + } + + async deleteActiveMission(missionId: string): Promise { + await this.redis.del(`${ACTIVE_MISSION_PREFIX}${missionId}`); + } + + async getChannelMissionId(channelId: string): Promise { + return this.redis.get(CHANNEL_MISSION_KEY(channelId)); + } + + async setChannelMissionId(channelId: string, missionId: string): Promise { + await this.redis.set(CHANNEL_MISSION_KEY(channelId), missionId); + } + + async clearChannelMission(channelId: string): Promise { + await this.redis.del(CHANNEL_MISSION_KEY(channelId)); + } + + async getStateForChannel(channelId: string): Promise { + 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 { + await this.redis.zadd(TICK_QUEUE_KEY, nextTickAtMs, missionId); + } + + async removeMissionFromQueue(missionId: string): Promise { + await this.redis.zrem(TICK_QUEUE_KEY, missionId); + } + + async getDueMissionIds(nowMs: number): Promise { + return this.redis.zrangebyscore(TICK_QUEUE_KEY, '-inf', nowMs); + } + + // Distributed lock — SET NX PX with a unique token. + async acquireLock(missionId: string): Promise { + 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 { + // 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); + } +} diff --git a/apps/api/src/app/missions/missions.controller.ts b/apps/api/src/app/missions/missions.controller.ts new file mode 100644 index 0000000..d1f9601 --- /dev/null +++ b/apps/api/src/app/missions/missions.controller.ts @@ -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 { + 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 { + 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); + } +} diff --git a/apps/api/src/app/missions/missions.module.ts b/apps/api/src/app/missions/missions.module.ts new file mode 100644 index 0000000..b26ff7a --- /dev/null +++ b/apps/api/src/app/missions/missions.module.ts @@ -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 {} diff --git a/apps/api/src/app/missions/missions.service.ts b/apps/api/src/app/missions/missions.service.ts new file mode 100644 index 0000000..c3b57f0 --- /dev/null +++ b/apps/api/src/app/missions/missions.service.ts @@ -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> { + 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 = { + 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)}`; +} diff --git a/apps/api/src/app/redis/redis.module.ts b/apps/api/src/app/redis/redis.module.ts new file mode 100644 index 0000000..bc0516a --- /dev/null +++ b/apps/api/src/app/redis/redis.module.ts @@ -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 {} diff --git a/apps/api/src/app/tick-engine/tick-engine.module.ts b/apps/api/src/app/tick-engine/tick-engine.module.ts new file mode 100644 index 0000000..16b1fe2 --- /dev/null +++ b/apps/api/src/app/tick-engine/tick-engine.module.ts @@ -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 {} diff --git a/apps/api/src/app/tick-engine/tick.service.ts b/apps/api/src/app/tick-engine/tick.service.ts new file mode 100644 index 0000000..11cf957 --- /dev/null +++ b/apps/api/src/app/tick-engine/tick.service.ts @@ -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 { + 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 { + 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); + } + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 27cc058..2ffd74b 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -12,7 +12,7 @@ async function bootstrap() { const globalPrefix = 'api'; app.setGlobalPrefix(globalPrefix); const port = process.env.PORT || 3000; - await app.listen(port); + await app.listen(port, '0.0.0.0'); Logger.log( `🚀 Application is running on: http://localhost:${port}/${globalPrefix}`, ); diff --git a/apps/overlay/project.json b/apps/overlay/project.json index 30cdcb4..6bfafc4 100644 --- a/apps/overlay/project.json +++ b/apps/overlay/project.json @@ -62,8 +62,10 @@ "executor": "@nx/eslint:lint" }, "test": { - "executor": "@angular/build:unit-test", - "options": {} + "executor": "@nx/vitest:test", + "options": { + "configFile": "apps/overlay/vitest.config.mts" + } }, "serve-static": { "continuous": true, diff --git a/apps/overlay/src/app/app.config.ts b/apps/overlay/src/app/app.config.ts index 9e7120f..66c3ccc 100644 --- a/apps/overlay/src/app/app.config.ts +++ b/apps/overlay/src/app/app.config.ts @@ -2,9 +2,12 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners, } from '@angular/core'; -import { provideRouter } from '@angular/router'; -import { appRoutes } from './app.routes'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { authInterceptor } from './ebs/auth.interceptor'; export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners(), provideRouter(appRoutes)], + providers: [ + provideBrowserGlobalErrorListeners(), + provideHttpClient(withInterceptors([authInterceptor])), + ], }; diff --git a/apps/overlay/src/app/app.spec.ts b/apps/overlay/src/app/app.spec.ts deleted file mode 100644 index c9d554e..0000000 --- a/apps/overlay/src/app/app.spec.ts +++ /dev/null @@ -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', - ); - }); -}); diff --git a/apps/overlay/src/app/app.ts b/apps/overlay/src/app/app.ts index 3b63913..7c42724 100644 --- a/apps/overlay/src/app/app.ts +++ b/apps/overlay/src/app/app.ts @@ -1,13 +1,9 @@ import { Component } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { NxWelcome } from './nx-welcome'; +import { PanelShellComponent } from './panel/panel-shell.component'; @Component({ - imports: [NxWelcome, RouterModule], + imports: [PanelShellComponent], selector: 'app-root', - templateUrl: './app.html', - styleUrl: './app.css', + template: ``, }) -export class App { - protected title = 'overlay'; -} +export class App {} diff --git a/apps/overlay/src/app/ebs/auth.interceptor.spec.ts b/apps/overlay/src/app/ebs/auth.interceptor.spec.ts new file mode 100644 index 0000000..435ca0e --- /dev/null +++ b/apps/overlay/src/app/ebs/auth.interceptor.spec.ts @@ -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; + }); +}); diff --git a/apps/overlay/src/app/ebs/auth.interceptor.ts b/apps/overlay/src/app/ebs/auth.interceptor.ts new file mode 100644 index 0000000..e11093e --- /dev/null +++ b/apps/overlay/src/app/ebs/auth.interceptor.ts @@ -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}` } })); +}; diff --git a/apps/overlay/src/app/ebs/ebs-api.service.spec.ts b/apps/overlay/src/app/ebs/ebs-api.service.spec.ts new file mode 100644 index 0000000..ad80e2a --- /dev/null +++ b/apps/overlay/src/app/ebs/ebs-api.service.spec.ts @@ -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`); + }); + }); +}); diff --git a/apps/overlay/src/app/ebs/ebs-api.service.ts b/apps/overlay/src/app/ebs/ebs-api.service.ts new file mode 100644 index 0000000..dbb0f53 --- /dev/null +++ b/apps/overlay/src/app/ebs/ebs-api.service.ts @@ -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('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 { + 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))); + } +} diff --git a/apps/overlay/src/app/mission/mission-state.store.spec.ts b/apps/overlay/src/app/mission/mission-state.store.spec.ts new file mode 100644 index 0000000..59b63ae --- /dev/null +++ b/apps/overlay/src/app/mission/mission-state.store.spec.ts @@ -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 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); + }); + }); +}); diff --git a/apps/overlay/src/app/mission/mission-state.store.ts b/apps/overlay/src/app/mission/mission-state.store.ts new file mode 100644 index 0000000..48b9c8d --- /dev/null +++ b/apps/overlay/src/app/mission/mission-state.store.ts @@ -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(null); + private readonly _loading = signal(false); + private readonly _error = signal(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)); + } +} diff --git a/apps/overlay/src/app/panel/ambient-event.component.ts b/apps/overlay/src/app/panel/ambient-event.component.ts new file mode 100644 index 0000000..01a0ab0 --- /dev/null +++ b/apps/overlay/src/app/panel/ambient-event.component.ts @@ -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: ` +
+ {{ event().type }} + {{ event().description }} +
+ `, +}) +export class AmbientEventComponent { + event = input.required(); + dismissed = output(); +} diff --git a/apps/overlay/src/app/panel/expanded-panel.component.ts b/apps/overlay/src/app/panel/expanded-panel.component.ts new file mode 100644 index 0000000..591431c --- /dev/null +++ b/apps/overlay/src/app/panel/expanded-panel.component.ts @@ -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: ` +
+ + @if (survivor(); as s) { +
+
{{ s.name }}
+
{{ s.state }}
+
+ @for (perk of s.perkSlots; track perk.id) { + {{ perk.name }} + } @empty { + No perks equipped + } +
+
+ } + @if (mission(); as m) { +
+ {{ difficultyGlyphs() }} + T+{{ m.tickIndex }} +
+ } +
+ @for (entry of recentLog(); track entry.tickIndex) { +
{{ entry.logText }}
+ } +
+
+ `, +}) +export class ExpandedPanelComponent { + missionState = input.required>(); + close = output(); + + 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) + ); +} diff --git a/apps/overlay/src/app/panel/minimised-panel.component.ts b/apps/overlay/src/app/panel/minimised-panel.component.ts new file mode 100644 index 0000000..5f01720 --- /dev/null +++ b/apps/overlay/src/app/panel/minimised-panel.component.ts @@ -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: ` +
+
+
+ @if (latestLogLine(); as line) { + {{ line }} + } @else { + The fog stirs… + } +
+
+ `, +}) +export class MinimisedPanelComponent { + missionState = input.required>(); + lanternClick = output(); + + 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; + }); +} diff --git a/apps/overlay/src/app/panel/panel-shell.component.spec.ts b/apps/overlay/src/app/panel/panel-shell.component.spec.ts new file mode 100644 index 0000000..cec833e --- /dev/null +++ b/apps/overlay/src/app/panel/panel-shell.component.spec.ts @@ -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; + @Output() lanternClick = new EventEmitter(); +} + +@Component({ selector: 'app-ambient-event', standalone: true, template: '' }) +class AmbientEventStub { + @Input() event!: AmbientEventData; + @Output() dismissed = new EventEmitter(); +} + +@Component({ selector: 'app-expanded-panel', standalone: true, template: '' }) +class ExpandedPanelStub { + @Input() missionState!: NonNullable; + @Output() close = new EventEmitter(); +} + +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: ``, +}) +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(); + }); +}); diff --git a/apps/overlay/src/app/panel/panel-shell.component.ts b/apps/overlay/src/app/panel/panel-shell.component.ts new file mode 100644 index 0000000..7181d70 --- /dev/null +++ b/apps/overlay/src/app/panel/panel-shell.component.ts @@ -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, + curr: NonNullable +): 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()) { +
+
+
+ } @else if (!store.state()) { +
+

The fog stirs. Awaiting a survivor.

+
+ } @else { + @switch (view()) { + @case ('minimised') { + + } + @case ('ambient') { + + } + @case ('expanded') { + + } + } + } + } + `, + 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('minimised'); + protected readonly pendingEvent = signal(null); + + private prevState: MissionStateResponse = null; + private ambientTimer: ReturnType | 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; + } + } +} diff --git a/apps/overlay/src/app/twitch/twitch-auth.service.spec.ts b/apps/overlay/src/app/twitch/twitch-auth.service.spec.ts new file mode 100644 index 0000000..d6b2461 --- /dev/null +++ b/apps/overlay/src/app/twitch/twitch-auth.service.spec.ts @@ -0,0 +1,107 @@ +import { TwitchAuthService, TwitchJwtPayload } from './twitch-auth.service'; + +function makeJwt(payload: Partial = {}): 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 = {}): 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); + }); +}); diff --git a/apps/overlay/src/app/twitch/twitch-auth.service.ts b/apps/overlay/src/app/twitch/twitch-auth.service.ts new file mode 100644 index 0000000..d18decf --- /dev/null +++ b/apps/overlay/src/app/twitch/twitch-auth.service.ts @@ -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(null); + private readonly _context = signal(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); + }); + } + } +} diff --git a/apps/overlay/src/test-setup.ts b/apps/overlay/src/test-setup.ts new file mode 100644 index 0000000..c059d42 --- /dev/null +++ b/apps/overlay/src/test-setup.ts @@ -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, +}); diff --git a/apps/overlay/src/twitch-ext.d.ts b/apps/overlay/src/twitch-ext.d.ts new file mode 100644 index 0000000..f325877 --- /dev/null +++ b/apps/overlay/src/twitch-ext.d.ts @@ -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 }; +} diff --git a/apps/overlay/vitest.config.mts b/apps/overlay/vitest.config.mts new file mode 100644 index 0000000..c285762 --- /dev/null +++ b/apps/overlay/vitest.config.mts @@ -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, + }, + }, +})); diff --git a/docs/adr/0008-angular-for-overlay-frontend.md b/docs/adr/0008-angular-for-overlay-frontend.md new file mode 100644 index 0000000..c4cebd6 --- /dev/null +++ b/docs/adr/0008-angular-for-overlay-frontend.md @@ -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; ~15–20 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 (~120–200 KB gzipped vs ~15–20 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index b1ff430..93980b1 100755 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 | | [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 | +| [0008](./0008-angular-for-overlay-frontend.md) | Angular for the overlay frontend | Accepted | ## When to write a new ADR diff --git a/libs/api-interfaces/package.json b/libs/api-interfaces/package.json index bc66b85..67621cb 100644 --- a/libs/api-interfaces/package.json +++ b/libs/api-interfaces/package.json @@ -6,8 +6,9 @@ "main": "./src/index.js", "types": "./src/index.d.ts", "dependencies": { + "@nx/vite": "^22.7.1", "tslib": "^2.3.0", "vitest": "^4.0.8", - "@nx/vite": "^22.7.1" + "zod": "^4.4.3" } } diff --git a/libs/api-interfaces/src/index.ts b/libs/api-interfaces/src/index.ts index 66f2284..b880dd4 100644 --- a/libs/api-interfaces/src/index.ts +++ b/libs/api-interfaces/src/index.ts @@ -2,3 +2,5 @@ export * from './lib/perk'; export * from './lib/survivor'; export * from './lib/mission'; export * from './lib/encounter'; +export * from './lib/encounter-definition'; +export * from './lib/mission-state'; diff --git a/libs/api-interfaces/src/lib/encounter-definition.ts b/libs/api-interfaces/src/lib/encounter-definition.ts new file mode 100644 index 0000000..09e22dc --- /dev/null +++ b/libs/api-interfaces/src/lib/encounter-definition.ts @@ -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; diff --git a/libs/api-interfaces/src/lib/mission-state.ts b/libs/api-interfaces/src/lib/mission-state.ts new file mode 100644 index 0000000..6b5815d --- /dev/null +++ b/libs/api-interfaces/src/lib/mission-state.ts @@ -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; + +export const StartMissionRequestSchema = z.object({ + difficulty: z.number().int().min(1).max(3), +}); +export type StartMissionRequest = z.infer; + +export const ChoosePerkRequestSchema = z.object({ + perkKey: z.string().min(1), +}); +export type ChoosePerkRequest = z.infer; diff --git a/libs/encounter-library/eslint.config.mjs b/libs/encounter-library/eslint.config.mjs index 68e70dc..1869a74 100644 --- a/libs/encounter-library/eslint.config.mjs +++ b/libs/encounter-library/eslint.config.mjs @@ -11,6 +11,7 @@ export default [ ignoredFiles: [ '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', '{projectRoot}/vite.config.{js,ts,mjs,mts}', + '{projectRoot}/vitest.config.{js,ts,mjs,mts}', ], }, ], diff --git a/libs/encounter-library/package.json b/libs/encounter-library/package.json index 5309080..166098f 100644 --- a/libs/encounter-library/package.json +++ b/libs/encounter-library/package.json @@ -7,6 +7,9 @@ "types": "./src/index.d.ts", "dependencies": { "tslib": "^2.3.0", + "@fog-explorer/api-interfaces": "*" + }, + "devDependencies": { "vitest": "^4.0.8", "@nx/vite": "^22.7.1" } diff --git a/libs/encounter-library/project.json b/libs/encounter-library/project.json index d20b322..7c6e258 100644 --- a/libs/encounter-library/project.json +++ b/libs/encounter-library/project.json @@ -12,7 +12,14 @@ "outputPath": "dist/libs/encounter-library", "main": "libs/encounter-library/src/index.ts", "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" + } + ] } } } diff --git a/libs/encounter-library/src/lib/encounter-library.spec.ts b/libs/encounter-library/src/lib/encounter-library.spec.ts index 9cb50b2..c154da2 100644 --- a/libs/encounter-library/src/lib/encounter-library.spec.ts +++ b/libs/encounter-library/src/lib/encounter-library.spec.ts @@ -1,7 +1,64 @@ -import { encounterLibrary } from './encounter-library'; +import { + getEncounterById, + getEncountersByTier, + getLibraryVersion, + getRandomEncounterByTier, + pickFlavor, +} from './encounter-library'; -describe('encounterLibrary', () => { - it('should work', () => { - expect(encounterLibrary()).toEqual('encounter-library'); +function seqRng(values: number[]): () => number { + let i = 0; + 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); + } + } }); }); diff --git a/libs/encounter-library/src/lib/encounter-library.ts b/libs/encounter-library/src/lib/encounter-library.ts index 3fc85ab..cbb4e5a 100644 --- a/libs/encounter-library/src/lib/encounter-library.ts +++ b/libs/encounter-library/src/lib/encounter-library.ts @@ -1,3 +1,70 @@ -export function encounterLibrary(): string { - return 'encounter-library'; +import type { EncounterDefinition } from '@fog-explorer/api-interfaces'; + +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)]; } diff --git a/libs/encounter-library/src/lib/encounters.json b/libs/encounter-library/src/lib/encounters.json new file mode 100644 index 0000000..d1f93f2 --- /dev/null +++ b/libs/encounter-library/src/lib/encounters.json @@ -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." + ] + } + ] +} diff --git a/libs/encounter-library/tsconfig.lib.json b/libs/encounter-library/tsconfig.lib.json index 54b671b..f12451e 100644 --- a/libs/encounter-library/tsconfig.lib.json +++ b/libs/encounter-library/tsconfig.lib.json @@ -3,7 +3,8 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "declaration": true, - "types": ["node"] + "types": ["node"], + "resolveJsonModule": true }, "include": ["src/**/*.ts"], "exclude": [ diff --git a/libs/encounter-library/tsconfig.spec.json b/libs/encounter-library/tsconfig.spec.json index 56b7488..60eed81 100644 --- a/libs/encounter-library/tsconfig.spec.json +++ b/libs/encounter-library/tsconfig.spec.json @@ -2,6 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", + "resolveJsonModule": true, "types": [ "vitest/globals", "vitest/importMeta", diff --git a/libs/mission-logic/eslint.config.mjs b/libs/mission-logic/eslint.config.mjs index 68e70dc..1869a74 100644 --- a/libs/mission-logic/eslint.config.mjs +++ b/libs/mission-logic/eslint.config.mjs @@ -11,6 +11,7 @@ export default [ ignoredFiles: [ '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', '{projectRoot}/vite.config.{js,ts,mjs,mts}', + '{projectRoot}/vitest.config.{js,ts,mjs,mts}', ], }, ], diff --git a/libs/mission-logic/package.json b/libs/mission-logic/package.json index 45f033e..1a0c864 100644 --- a/libs/mission-logic/package.json +++ b/libs/mission-logic/package.json @@ -7,6 +7,10 @@ "types": "./src/index.d.ts", "dependencies": { "tslib": "^2.3.0", + "seedrandom": "^3.0.5", + "@fog-explorer/api-interfaces": "*" + }, + "devDependencies": { "vitest": "^4.0.8", "@nx/vite": "^22.7.1" } diff --git a/libs/mission-logic/src/index.ts b/libs/mission-logic/src/index.ts index 1ceec53..8b54e17 100644 --- a/libs/mission-logic/src/index.ts +++ b/libs/mission-logic/src/index.ts @@ -1 +1 @@ -export * from './lib/mission-logic'; +export * from './lib/encounter-resolver'; diff --git a/libs/mission-logic/src/lib/encounter-resolver.spec.ts b/libs/mission-logic/src/lib/encounter-resolver.spec.ts new file mode 100644 index 0000000..a023626 --- /dev/null +++ b/libs/mission-logic/src/lib/encounter-resolver.spec.ts @@ -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 => + 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 = {}): fc.Arbitrary => + 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([]), + hookCount: fc.integer({ min: 0, max: 2 }), + }), + }).map((base) => ({ ...base, ...overrides })); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeInput(partial: Partial = {}): 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 = 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): 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 55–75%', () => { + 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 30–55%', () => { + 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); + }); +}); diff --git a/libs/mission-logic/src/lib/encounter-resolver.ts b/libs/mission-logic/src/lib/encounter-resolver.ts new file mode 100644 index 0000000..9055121 --- /dev/null +++ b/libs/mission-logic/src/lib/encounter-resolver.ts @@ -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 = { + '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; +} diff --git a/libs/mission-logic/src/lib/mission-logic.spec.ts b/libs/mission-logic/src/lib/mission-logic.spec.ts deleted file mode 100644 index 9dd1d1c..0000000 --- a/libs/mission-logic/src/lib/mission-logic.spec.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { missionLogic } from './mission-logic'; - -describe('missionLogic', () => { - it('should work', () => { - expect(missionLogic()).toEqual('mission-logic'); - }); -}); diff --git a/libs/mission-logic/src/lib/mission-logic.ts b/libs/mission-logic/src/lib/mission-logic.ts deleted file mode 100644 index 59a75ad..0000000 --- a/libs/mission-logic/src/lib/mission-logic.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function missionLogic(): string { - return 'mission-logic'; -} diff --git a/nx.json b/nx.json index df3a5c9..7a3a0d2 100644 --- a/nx.json +++ b/nx.json @@ -50,7 +50,6 @@ "plugin": "@nx/vitest", "options": { "testTargetName": "test", - "ciTargetName": "test-ci", "testMode": "watch" } } diff --git a/package.json b/package.json index 04dbd99..e50407c 100644 --- a/package.json +++ b/package.json @@ -36,12 +36,15 @@ "@swc/helpers": "~0.5.18", "@types/jest": "^30.0.0", "@types/node": "20.19.9", + "@types/seedrandom": "^3.0.8", "@typescript-eslint/utils": "^8.40.0", "@vitest/coverage-v8": "~4.1.0", "angular-eslint": "^21.2.0", "eslint": "^9.8.0", "eslint-config-prettier": "^10.0.0", "eslint-plugin-playwright": "^1.6.2", + "fast-check": "^4.7.0", + "happy-dom": "^20.9.0", "jest": "^30.0.2", "jest-environment-node": "^30.0.2", "jest-util": "^30.0.2", @@ -68,9 +71,12 @@ "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", "@nestjs/platform-express": "^11.0.0", + "@nestjs/schedule": "^6.1.3", "axios": "^1.6.0", + "ioredis": "^5.10.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", + "seedrandom": "^3.0.5", "zod": "^4.4.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bb204c..a33d8ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,15 +35,24 @@ importers: '@nestjs/platform-express': 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) + '@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: specifier: ^1.6.0 version: 1.15.0 + ioredis: + specifier: ^5.10.1 + version: 5.10.1 reflect-metadata: specifier: ^0.1.13 version: 0.1.14 rxjs: specifier: ^7.8.0 version: 7.8.2 + seedrandom: + specifier: ^3.0.5 + version: 3.0.5 zod: specifier: ^4.4.3 version: 4.4.3 @@ -141,6 +150,9 @@ importers: '@types/node': specifier: 20.19.9 version: 20.19.9 + '@types/seedrandom': + specifier: ^3.0.8 + version: 3.0.8 '@typescript-eslint/utils': specifier: ^8.40.0 version: 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) @@ -159,6 +171,12 @@ importers: eslint-plugin-playwright: specifier: ^1.6.2 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: 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)) @@ -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) vitest: 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: specifier: ^5.1.4 version: 5.1.4(webpack@5.106.2) @@ -1541,6 +1559,9 @@ packages: '@types/node': optional: true + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -2139,6 +2160,12 @@ packages: '@nestjs/common': ^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': resolution: {integrity: sha512-lVxGZ46tcdItFMoXr6vyKWlnOsm1SZm/GUqAEDvy2RL4Q4O+3bkziAhrO7Y8JLssFUUvNFEGqAizI52WAxhjDw==} peerDependencies: @@ -3261,6 +3288,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/luxon@3.7.1': + resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -3282,6 +3312,9 @@ packages: '@types/retry@0.12.2': 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': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} @@ -3303,6 +3336,9 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -4068,6 +4104,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} 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: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -4221,6 +4261,10 @@ packages: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} 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: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -4392,6 +4436,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -4732,6 +4780,10 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + fast-check@4.7.0: + resolution: {integrity: sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==} + engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -4991,6 +5043,10 @@ packages: engines: {node: '>=0.4.7'} 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: resolution: {integrity: sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==} @@ -5178,6 +5234,10 @@ packages: resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} 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: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -5733,6 +5793,12 @@ packages: lodash.debounce@4.0.8: 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: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} @@ -6600,6 +6666,9 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + pure-rand@8.4.0: + resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + pvtsutils@1.3.6: resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} @@ -6660,6 +6729,14 @@ packages: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} 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: resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==} @@ -6944,6 +7021,9 @@ packages: secure-compare@3.0.1: resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==} + seedrandom@3.0.5: + resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==} + select-hose@2.0.0: resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==} @@ -7147,6 +7227,9 @@ packages: stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -7854,6 +7937,10 @@ packages: engines: {node: '>=12'} 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: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -8268,7 +8355,7 @@ snapshots: less: 4.5.1 lmdb: 3.5.1 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: - '@emnapi/core' - '@emnapi/runtime' @@ -9526,6 +9613,8 @@ snapshots: optionalDependencies: '@types/node': 20.19.9 + '@ioredis/commands@1.5.1': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -10282,6 +10371,12 @@ snapshots: transitivePeerDependencies: - 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)': dependencies: '@angular-devkit/core': 19.2.24 @@ -10781,7 +10876,7 @@ snapshots: tsconfig-paths: 4.2.0 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) - 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: - '@babel/traverse' - '@nx/eslint' @@ -10803,7 +10898,7 @@ snapshots: optionalDependencies: '@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) - 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: - '@babel/traverse' - '@swc-node/register' @@ -11704,6 +11799,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/luxon@3.7.1': {} + '@types/mime@1.3.5': {} '@types/node-forge@1.3.14': @@ -11722,6 +11819,8 @@ snapshots: '@types/retry@0.12.2': {} + '@types/seedrandom@3.0.8': {} + '@types/semver@7.5.8': {} '@types/send@0.17.6': @@ -11749,6 +11848,8 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/ws@8.18.1': dependencies: '@types/node': 20.19.9 @@ -11927,7 +12028,7 @@ snapshots: obug: 2.1.1 std-env: 4.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': dependencies: @@ -12624,6 +12725,8 @@ snapshots: clone@1.0.4: {} + cluster-key-slot@1.1.2: {} + co@4.6.0: {} collect-v8-coverage@1.0.3: {} @@ -12767,6 +12870,11 @@ snapshots: dependencies: luxon: 3.7.2 + cron@4.4.0: + dependencies: + '@types/luxon': 3.7.1 + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -12935,6 +13043,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@1.1.2: {} depd@2.0.0: {} @@ -13336,6 +13446,10 @@ snapshots: transitivePeerDependencies: - supports-color + fast-check@4.7.0: + dependencies: + pure-rand: 8.4.0 + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -13612,6 +13726,18 @@ snapshots: optionalDependencies: 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: {} has-flag@4.0.0: {} @@ -13818,6 +13944,20 @@ snapshots: 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-regex@4.3.0: {} @@ -14512,6 +14652,10 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} @@ -15527,6 +15671,8 @@ snapshots: pure-rand@7.0.1: {} + pure-rand@8.4.0: {} + pvtsutils@1.3.6: dependencies: tslib: 2.8.1 @@ -15593,6 +15739,12 @@ snapshots: dependencies: 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.2.2: {} @@ -15908,6 +16060,8 @@ snapshots: secure-compare@3.0.1: {} + seedrandom@3.0.5: {} + select-hose@2.0.0: {} selfsigned@2.4.1: @@ -16166,6 +16320,8 @@ snapshots: stackframe@1.3.4: {} + standard-as-callback@2.1.0: {} + statuses@1.5.0: {} statuses@2.0.2: {} @@ -16652,7 +16808,7 @@ snapshots: 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)): dependencies: '@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)) @@ -16677,6 +16833,7 @@ snapshots: optionalDependencies: '@types/node': 20.19.9 '@vitest/coverage-v8': 4.1.5(vitest@4.1.5) + happy-dom: 20.9.0 jsdom: 27.4.0 transitivePeerDependencies: - msw @@ -16839,6 +16996,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} whatwg-mimetype@5.0.0: {}