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: `
+
+ `
+})
+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: `
+
+ `
+})
+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: {}