Refactor API and enhance Angular integration
- Removed `ciTargetName` from `nx.json`. - Updated `package.json` to include new dependencies: `@types/seedrandom`, `fast-check`, `happy-dom`, and `@nestjs/schedule`. - Modified `pnpm-lock.yaml` to reflect the addition of new packages and their versions. - Improved project documentation in `PROJECT_CONTEXT.md` to clarify the use of Zod schemas and Angular framework decisions. - Introduced new Angular components and patterns in the `.agents/skills/frontend-angular` directory, including examples and reference materials for Angular 21+ features.
This commit is contained in:
10
.agents/skills/frontend-angular/.skillfish.json
Normal file
10
.agents/skills/frontend-angular/.skillfish.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 2,
|
||||
"name": "frontend-angular",
|
||||
"owner": "ai-enhanced-engineer",
|
||||
"repo": "aiee-team",
|
||||
"path": "skills/frontend-angular",
|
||||
"branch": "main",
|
||||
"sha": "9742140f3ba1399701796d131407d6f41983074f",
|
||||
"source": "manual"
|
||||
}
|
||||
190
.agents/skills/frontend-angular/SKILL.md
Normal file
190
.agents/skills/frontend-angular/SKILL.md
Normal file
@@ -0,0 +1,190 @@
|
||||
---
|
||||
name: frontend-angular
|
||||
description: Modern Angular 21+ patterns including signals, standalone components, zoneless change detection, and new control flow syntax. Use for Angular architecture decisions or implementing components with latest APIs.
|
||||
trigger-terms: Angular 21+, signals, standalone components, zoneless, control flow, @if, @for
|
||||
---
|
||||
|
||||
# Angular Production Patterns
|
||||
|
||||
Production-ready patterns for Angular 21+ applications with zoneless change detection.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Signal-Based Reactivity
|
||||
|
||||
```typescript
|
||||
import { Component, signal, computed, effect } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-counter',
|
||||
standalone: true,
|
||||
template: `
|
||||
<button (click)="increment()">
|
||||
{{ count() }} × 2 = {{ doubled() }}
|
||||
</button>
|
||||
`
|
||||
})
|
||||
export class CounterComponent {
|
||||
count = signal(0);
|
||||
doubled = computed(() => this.count() * 2);
|
||||
|
||||
constructor() {
|
||||
effect(() => console.log('Count changed:', this.count()));
|
||||
}
|
||||
|
||||
increment() {
|
||||
this.count.update(n => n + 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key APIs
|
||||
|
||||
| API | Purpose |
|
||||
|-----|---------|
|
||||
| `signal()` | Reactive state declaration |
|
||||
| `computed()` | Derived values (auto-tracked) |
|
||||
| `effect()` | Side effects on signal changes |
|
||||
| `input()` | Component inputs (replaces @Input) |
|
||||
| `output()` | Component outputs (replaces @Output) |
|
||||
| `model()` | Two-way bindable signals |
|
||||
|
||||
## Modern Control Flow
|
||||
|
||||
```html
|
||||
<!-- Conditionals -->
|
||||
@if (isLoading()) {
|
||||
<app-spinner />
|
||||
} @else if (hasError()) {
|
||||
<app-error [message]="error()" />
|
||||
} @else {
|
||||
<app-content [data]="data()" />
|
||||
}
|
||||
|
||||
<!-- Loops with tracking -->
|
||||
@for (item of items(); track item.id) {
|
||||
<app-item [data]="item" />
|
||||
} @empty {
|
||||
<p>No items found</p>
|
||||
}
|
||||
|
||||
<!-- Switch -->
|
||||
@switch (status()) {
|
||||
@case ('pending') { <app-pending /> }
|
||||
@case ('active') { <app-active /> }
|
||||
@default { <app-unknown /> }
|
||||
}
|
||||
```
|
||||
|
||||
## Standalone Architecture
|
||||
|
||||
```typescript
|
||||
// No NgModules - components declare their dependencies
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule, MetricsComponent],
|
||||
template: `...`
|
||||
})
|
||||
export class DashboardComponent {}
|
||||
|
||||
// Bootstrapping
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor]))
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
## Component I/O (Angular 21+)
|
||||
|
||||
```typescript
|
||||
@Component({...})
|
||||
export class UserCardComponent {
|
||||
// Input signal (required)
|
||||
user = input.required<User>();
|
||||
|
||||
// Input with default
|
||||
showAvatar = input(true);
|
||||
|
||||
// Output
|
||||
selected = output<User>();
|
||||
|
||||
// Two-way binding
|
||||
isExpanded = model(false);
|
||||
|
||||
onSelect() {
|
||||
this.selected.emit(this.user());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Targets
|
||||
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| LCP | < 2.5s |
|
||||
| INP | < 200ms |
|
||||
| CLS | < 0.1 |
|
||||
| Initial Bundle | < 250KB |
|
||||
|
||||
## When NOT to Use Angular
|
||||
|
||||
- Simple interactivity → Vanilla JS
|
||||
- Static marketing site → Astro/11ty
|
||||
- < 100KB JS budget → Svelte or Web Components
|
||||
- React ecosystem dependency → React
|
||||
|
||||
## Signal Testing Patterns
|
||||
|
||||
| Pattern | Use Case |
|
||||
|---------|----------|
|
||||
| PLATFORM_ID mocking | Prevent constructor side effects in SSR tests |
|
||||
| WritableSignal with `.set()` | Control signal state without reassignment |
|
||||
| NG0100 prevention | Initialize signals before `detectChanges()` |
|
||||
|
||||
## Signal-Based Service Pattern
|
||||
|
||||
Private writable signal → public readonly → update in `tap()` → component injects and reads:
|
||||
|
||||
```typescript
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AnalyticsService {
|
||||
private _summary = signal<Summary | null>(null);
|
||||
readonly summary = this._summary.asReadonly();
|
||||
|
||||
private _loading = signal(false);
|
||||
readonly loading = this._loading.asReadonly();
|
||||
|
||||
getSummary() {
|
||||
this._loading.set(true);
|
||||
return this.http.get<Summary>('/api/analytics/summary').pipe(
|
||||
tap(data => {
|
||||
this._summary.set(data);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._loading.set(false);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Component usage:**
|
||||
```typescript
|
||||
@Component({...})
|
||||
export class DashboardComponent {
|
||||
private analytics = inject(AnalyticsService);
|
||||
summary = this.analytics.summary;
|
||||
loading = this.analytics.loading;
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern:** Matches AuthService pattern for consistency across services.
|
||||
|
||||
See `examples.md` for full testing code patterns.
|
||||
|
||||
See `reference.md` for component libraries.
|
||||
83
.agents/skills/frontend-angular/examples.md
Normal file
83
.agents/skills/frontend-angular/examples.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Angular Examples
|
||||
|
||||
Production-ready component implementations for Angular 21+.
|
||||
|
||||
## Dashboard Metrics Component
|
||||
|
||||
```typescript
|
||||
import { Component, input, computed } from '@angular/core';
|
||||
import { DecimalPipe, PercentPipe } from '@angular/common';
|
||||
|
||||
interface Metric {
|
||||
label: string;
|
||||
value: number;
|
||||
format: 'number' | 'currency' | 'percent';
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-metrics-widget',
|
||||
standalone: true,
|
||||
imports: [DecimalPipe, PercentPipe],
|
||||
template: `
|
||||
<div class="metrics-grid" role="region" aria-labelledby="metrics-heading">
|
||||
<h2 id="metrics-heading" class="sr-only">Key Metrics</h2>
|
||||
|
||||
@for (metric of metrics(); track metric.label) {
|
||||
<article
|
||||
class="metric-card"
|
||||
[attr.aria-label]="metric.label + ': ' + formatValue(metric)">
|
||||
<span class="label">{{ metric.label }}</span>
|
||||
<span class="value">{{ formatValue(metric) }}</span>
|
||||
<span class="trend" [class]="metric.trend">
|
||||
@switch (metric.trend) {
|
||||
@case ('up') { ↑ }
|
||||
@case ('down') { ↓ }
|
||||
@default { → }
|
||||
}
|
||||
</span>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.metric-card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.trend.up { color: var(--green-500); }
|
||||
.trend.down { color: var(--red-500); }
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class MetricsWidgetComponent {
|
||||
metrics = input.required<Metric[]>();
|
||||
|
||||
formatValue(metric: Metric): string {
|
||||
switch (metric.format) {
|
||||
case 'currency':
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(metric.value);
|
||||
case 'percent':
|
||||
return `${(metric.value * 100).toFixed(1)}%`;
|
||||
default:
|
||||
return metric.value.toLocaleString();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
174
.agents/skills/frontend-angular/reference.md
Normal file
174
.agents/skills/frontend-angular/reference.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Angular Reference
|
||||
|
||||
Detailed patterns and component library guidance for Angular 21+.
|
||||
|
||||
## Zoneless Change Detection (Default in v21)
|
||||
|
||||
```typescript
|
||||
// No Zone.js needed - signals trigger updates automatically
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
// Zoneless is default in Angular 21+
|
||||
// Only add this if you need Zone.js for legacy code:
|
||||
// provideZoneChangeDetection()
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Smaller bundle (no Zone.js ~100KB)
|
||||
- Predictable change detection
|
||||
- Better debugging (no Zone.js stack traces)
|
||||
- Signals automatically schedule updates
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
```typescript
|
||||
// Modern inject() function (preferred)
|
||||
@Component({...})
|
||||
export class UserService {
|
||||
private http = inject(HttpClient);
|
||||
private router = inject(Router);
|
||||
}
|
||||
|
||||
// Constructor injection (still valid)
|
||||
constructor(private http: HttpClient) {}
|
||||
```
|
||||
|
||||
## HTTP Client Patterns
|
||||
|
||||
```typescript
|
||||
// Functional interceptors (Angular 21+)
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const token = inject(AuthService).getToken();
|
||||
|
||||
if (token) {
|
||||
req = req.clone({
|
||||
setHeaders: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
}
|
||||
|
||||
return next(req);
|
||||
};
|
||||
|
||||
// Registration
|
||||
provideHttpClient(
|
||||
withInterceptors([authInterceptor, loggingInterceptor])
|
||||
)
|
||||
```
|
||||
|
||||
## Resource API (Async Data)
|
||||
|
||||
```typescript
|
||||
// Signal-based async data loading
|
||||
@Component({...})
|
||||
export class UsersComponent {
|
||||
private usersService = inject(UsersService);
|
||||
|
||||
searchQuery = signal('');
|
||||
|
||||
usersResource = resource({
|
||||
request: () => ({ query: this.searchQuery() }),
|
||||
loader: async ({ request }) => {
|
||||
return this.usersService.search(request.query);
|
||||
}
|
||||
});
|
||||
|
||||
// In template:
|
||||
// @if (usersResource.isLoading()) { ... }
|
||||
// @if (usersResource.hasValue()) { ... }
|
||||
// @if (usersResource.error()) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Component Libraries Comparison
|
||||
|
||||
| Library | Cost | Components | Best For |
|
||||
|---------|------|------------|----------|
|
||||
| **PrimeNG** | Free (MIT) | 80+ | B2B dashboards, forms |
|
||||
| **Kendo UI** | $1,028+/dev/year | 110+ | Enterprise, data grids |
|
||||
| **Angular Material** | Free | ~40 | Material Design apps |
|
||||
|
||||
## Reactive Forms with Signals
|
||||
|
||||
```typescript
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule],
|
||||
template: `
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<input formControlName="email" />
|
||||
@if (form.controls.email.errors?.['required']) {
|
||||
<span class="error">Email is required</span>
|
||||
}
|
||||
<button type="submit" [disabled]="form.invalid">Submit</button>
|
||||
</form>
|
||||
`
|
||||
})
|
||||
export class LoginComponent {
|
||||
private fb = inject(FormBuilder);
|
||||
|
||||
form = this.fb.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
password: ['', [Validators.required, Validators.minLength(8)]]
|
||||
});
|
||||
|
||||
onSubmit() {
|
||||
if (this.form.valid) {
|
||||
console.log(this.form.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Router Patterns
|
||||
|
||||
```typescript
|
||||
// Route configuration
|
||||
export const routes: Routes = [
|
||||
{ path: '', component: HomeComponent },
|
||||
{
|
||||
path: 'dashboard',
|
||||
loadComponent: () => import('./dashboard/dashboard.component')
|
||||
.then(m => m.DashboardComponent),
|
||||
canActivate: [authGuard]
|
||||
},
|
||||
{ path: '**', component: NotFoundComponent }
|
||||
];
|
||||
|
||||
// Functional guard
|
||||
export const authGuard: CanActivateFn = (route, state) => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (auth.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return router.createUrlTree(['/login'], {
|
||||
queryParams: { returnUrl: state.url }
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
// Global error handler
|
||||
@Injectable()
|
||||
export class GlobalErrorHandler implements ErrorHandler {
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
handleError(error: Error) {
|
||||
console.error('Unhandled error:', error);
|
||||
this.snackBar.open('An error occurred', 'Dismiss', {
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Provider
|
||||
{ provide: ErrorHandler, useClass: GlobalErrorHandler }
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
10
.claude/skills/frontend-angular/.skillfish.json
Normal file
10
.claude/skills/frontend-angular/.skillfish.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 2,
|
||||
"name": "frontend-angular",
|
||||
"owner": "ai-enhanced-engineer",
|
||||
"repo": "aiee-team",
|
||||
"path": "skills/frontend-angular",
|
||||
"branch": "main",
|
||||
"sha": "9742140f3ba1399701796d131407d6f41983074f",
|
||||
"source": "manual"
|
||||
}
|
||||
190
.claude/skills/frontend-angular/SKILL.md
Normal file
190
.claude/skills/frontend-angular/SKILL.md
Normal file
@@ -0,0 +1,190 @@
|
||||
---
|
||||
name: frontend-angular
|
||||
description: Modern Angular 21+ patterns including signals, standalone components, zoneless change detection, and new control flow syntax. Use for Angular architecture decisions or implementing components with latest APIs.
|
||||
trigger-terms: Angular 21+, signals, standalone components, zoneless, control flow, @if, @for
|
||||
---
|
||||
|
||||
# Angular Production Patterns
|
||||
|
||||
Production-ready patterns for Angular 21+ applications with zoneless change detection.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Signal-Based Reactivity
|
||||
|
||||
```typescript
|
||||
import { Component, signal, computed, effect } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-counter',
|
||||
standalone: true,
|
||||
template: `
|
||||
<button (click)="increment()">
|
||||
{{ count() }} × 2 = {{ doubled() }}
|
||||
</button>
|
||||
`
|
||||
})
|
||||
export class CounterComponent {
|
||||
count = signal(0);
|
||||
doubled = computed(() => this.count() * 2);
|
||||
|
||||
constructor() {
|
||||
effect(() => console.log('Count changed:', this.count()));
|
||||
}
|
||||
|
||||
increment() {
|
||||
this.count.update(n => n + 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Key APIs
|
||||
|
||||
| API | Purpose |
|
||||
|-----|---------|
|
||||
| `signal()` | Reactive state declaration |
|
||||
| `computed()` | Derived values (auto-tracked) |
|
||||
| `effect()` | Side effects on signal changes |
|
||||
| `input()` | Component inputs (replaces @Input) |
|
||||
| `output()` | Component outputs (replaces @Output) |
|
||||
| `model()` | Two-way bindable signals |
|
||||
|
||||
## Modern Control Flow
|
||||
|
||||
```html
|
||||
<!-- Conditionals -->
|
||||
@if (isLoading()) {
|
||||
<app-spinner />
|
||||
} @else if (hasError()) {
|
||||
<app-error [message]="error()" />
|
||||
} @else {
|
||||
<app-content [data]="data()" />
|
||||
}
|
||||
|
||||
<!-- Loops with tracking -->
|
||||
@for (item of items(); track item.id) {
|
||||
<app-item [data]="item" />
|
||||
} @empty {
|
||||
<p>No items found</p>
|
||||
}
|
||||
|
||||
<!-- Switch -->
|
||||
@switch (status()) {
|
||||
@case ('pending') { <app-pending /> }
|
||||
@case ('active') { <app-active /> }
|
||||
@default { <app-unknown /> }
|
||||
}
|
||||
```
|
||||
|
||||
## Standalone Architecture
|
||||
|
||||
```typescript
|
||||
// No NgModules - components declare their dependencies
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule, MetricsComponent],
|
||||
template: `...`
|
||||
})
|
||||
export class DashboardComponent {}
|
||||
|
||||
// Bootstrapping
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withInterceptors([authInterceptor]))
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
## Component I/O (Angular 21+)
|
||||
|
||||
```typescript
|
||||
@Component({...})
|
||||
export class UserCardComponent {
|
||||
// Input signal (required)
|
||||
user = input.required<User>();
|
||||
|
||||
// Input with default
|
||||
showAvatar = input(true);
|
||||
|
||||
// Output
|
||||
selected = output<User>();
|
||||
|
||||
// Two-way binding
|
||||
isExpanded = model(false);
|
||||
|
||||
onSelect() {
|
||||
this.selected.emit(this.user());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Targets
|
||||
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| LCP | < 2.5s |
|
||||
| INP | < 200ms |
|
||||
| CLS | < 0.1 |
|
||||
| Initial Bundle | < 250KB |
|
||||
|
||||
## When NOT to Use Angular
|
||||
|
||||
- Simple interactivity → Vanilla JS
|
||||
- Static marketing site → Astro/11ty
|
||||
- < 100KB JS budget → Svelte or Web Components
|
||||
- React ecosystem dependency → React
|
||||
|
||||
## Signal Testing Patterns
|
||||
|
||||
| Pattern | Use Case |
|
||||
|---------|----------|
|
||||
| PLATFORM_ID mocking | Prevent constructor side effects in SSR tests |
|
||||
| WritableSignal with `.set()` | Control signal state without reassignment |
|
||||
| NG0100 prevention | Initialize signals before `detectChanges()` |
|
||||
|
||||
## Signal-Based Service Pattern
|
||||
|
||||
Private writable signal → public readonly → update in `tap()` → component injects and reads:
|
||||
|
||||
```typescript
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AnalyticsService {
|
||||
private _summary = signal<Summary | null>(null);
|
||||
readonly summary = this._summary.asReadonly();
|
||||
|
||||
private _loading = signal(false);
|
||||
readonly loading = this._loading.asReadonly();
|
||||
|
||||
getSummary() {
|
||||
this._loading.set(true);
|
||||
return this.http.get<Summary>('/api/analytics/summary').pipe(
|
||||
tap(data => {
|
||||
this._summary.set(data);
|
||||
this._loading.set(false);
|
||||
}),
|
||||
catchError(err => {
|
||||
this._loading.set(false);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Component usage:**
|
||||
```typescript
|
||||
@Component({...})
|
||||
export class DashboardComponent {
|
||||
private analytics = inject(AnalyticsService);
|
||||
summary = this.analytics.summary;
|
||||
loading = this.analytics.loading;
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern:** Matches AuthService pattern for consistency across services.
|
||||
|
||||
See `examples.md` for full testing code patterns.
|
||||
|
||||
See `reference.md` for component libraries.
|
||||
83
.claude/skills/frontend-angular/examples.md
Normal file
83
.claude/skills/frontend-angular/examples.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Angular Examples
|
||||
|
||||
Production-ready component implementations for Angular 21+.
|
||||
|
||||
## Dashboard Metrics Component
|
||||
|
||||
```typescript
|
||||
import { Component, input, computed } from '@angular/core';
|
||||
import { DecimalPipe, PercentPipe } from '@angular/common';
|
||||
|
||||
interface Metric {
|
||||
label: string;
|
||||
value: number;
|
||||
format: 'number' | 'currency' | 'percent';
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-metrics-widget',
|
||||
standalone: true,
|
||||
imports: [DecimalPipe, PercentPipe],
|
||||
template: `
|
||||
<div class="metrics-grid" role="region" aria-labelledby="metrics-heading">
|
||||
<h2 id="metrics-heading" class="sr-only">Key Metrics</h2>
|
||||
|
||||
@for (metric of metrics(); track metric.label) {
|
||||
<article
|
||||
class="metric-card"
|
||||
[attr.aria-label]="metric.label + ': ' + formatValue(metric)">
|
||||
<span class="label">{{ metric.label }}</span>
|
||||
<span class="value">{{ formatValue(metric) }}</span>
|
||||
<span class="trend" [class]="metric.trend">
|
||||
@switch (metric.trend) {
|
||||
@case ('up') { ↑ }
|
||||
@case ('down') { ↓ }
|
||||
@default { → }
|
||||
}
|
||||
</span>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.metric-card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-card);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.trend.up { color: var(--green-500); }
|
||||
.trend.down { color: var(--red-500); }
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class MetricsWidgetComponent {
|
||||
metrics = input.required<Metric[]>();
|
||||
|
||||
formatValue(metric: Metric): string {
|
||||
switch (metric.format) {
|
||||
case 'currency':
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(metric.value);
|
||||
case 'percent':
|
||||
return `${(metric.value * 100).toFixed(1)}%`;
|
||||
default:
|
||||
return metric.value.toLocaleString();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
174
.claude/skills/frontend-angular/reference.md
Normal file
174
.claude/skills/frontend-angular/reference.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Angular Reference
|
||||
|
||||
Detailed patterns and component library guidance for Angular 21+.
|
||||
|
||||
## Zoneless Change Detection (Default in v21)
|
||||
|
||||
```typescript
|
||||
// No Zone.js needed - signals trigger updates automatically
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
// Zoneless is default in Angular 21+
|
||||
// Only add this if you need Zone.js for legacy code:
|
||||
// provideZoneChangeDetection()
|
||||
]
|
||||
});
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Smaller bundle (no Zone.js ~100KB)
|
||||
- Predictable change detection
|
||||
- Better debugging (no Zone.js stack traces)
|
||||
- Signals automatically schedule updates
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
```typescript
|
||||
// Modern inject() function (preferred)
|
||||
@Component({...})
|
||||
export class UserService {
|
||||
private http = inject(HttpClient);
|
||||
private router = inject(Router);
|
||||
}
|
||||
|
||||
// Constructor injection (still valid)
|
||||
constructor(private http: HttpClient) {}
|
||||
```
|
||||
|
||||
## HTTP Client Patterns
|
||||
|
||||
```typescript
|
||||
// Functional interceptors (Angular 21+)
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const token = inject(AuthService).getToken();
|
||||
|
||||
if (token) {
|
||||
req = req.clone({
|
||||
setHeaders: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
}
|
||||
|
||||
return next(req);
|
||||
};
|
||||
|
||||
// Registration
|
||||
provideHttpClient(
|
||||
withInterceptors([authInterceptor, loggingInterceptor])
|
||||
)
|
||||
```
|
||||
|
||||
## Resource API (Async Data)
|
||||
|
||||
```typescript
|
||||
// Signal-based async data loading
|
||||
@Component({...})
|
||||
export class UsersComponent {
|
||||
private usersService = inject(UsersService);
|
||||
|
||||
searchQuery = signal('');
|
||||
|
||||
usersResource = resource({
|
||||
request: () => ({ query: this.searchQuery() }),
|
||||
loader: async ({ request }) => {
|
||||
return this.usersService.search(request.query);
|
||||
}
|
||||
});
|
||||
|
||||
// In template:
|
||||
// @if (usersResource.isLoading()) { ... }
|
||||
// @if (usersResource.hasValue()) { ... }
|
||||
// @if (usersResource.error()) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Component Libraries Comparison
|
||||
|
||||
| Library | Cost | Components | Best For |
|
||||
|---------|------|------------|----------|
|
||||
| **PrimeNG** | Free (MIT) | 80+ | B2B dashboards, forms |
|
||||
| **Kendo UI** | $1,028+/dev/year | 110+ | Enterprise, data grids |
|
||||
| **Angular Material** | Free | ~40 | Material Design apps |
|
||||
|
||||
## Reactive Forms with Signals
|
||||
|
||||
```typescript
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule],
|
||||
template: `
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<input formControlName="email" />
|
||||
@if (form.controls.email.errors?.['required']) {
|
||||
<span class="error">Email is required</span>
|
||||
}
|
||||
<button type="submit" [disabled]="form.invalid">Submit</button>
|
||||
</form>
|
||||
`
|
||||
})
|
||||
export class LoginComponent {
|
||||
private fb = inject(FormBuilder);
|
||||
|
||||
form = this.fb.group({
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
password: ['', [Validators.required, Validators.minLength(8)]]
|
||||
});
|
||||
|
||||
onSubmit() {
|
||||
if (this.form.valid) {
|
||||
console.log(this.form.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Router Patterns
|
||||
|
||||
```typescript
|
||||
// Route configuration
|
||||
export const routes: Routes = [
|
||||
{ path: '', component: HomeComponent },
|
||||
{
|
||||
path: 'dashboard',
|
||||
loadComponent: () => import('./dashboard/dashboard.component')
|
||||
.then(m => m.DashboardComponent),
|
||||
canActivate: [authGuard]
|
||||
},
|
||||
{ path: '**', component: NotFoundComponent }
|
||||
];
|
||||
|
||||
// Functional guard
|
||||
export const authGuard: CanActivateFn = (route, state) => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (auth.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return router.createUrlTree(['/login'], {
|
||||
queryParams: { returnUrl: state.url }
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
// Global error handler
|
||||
@Injectable()
|
||||
export class GlobalErrorHandler implements ErrorHandler {
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
handleError(error: Error) {
|
||||
console.error('Unhandled error:', error);
|
||||
this.snackBar.open('An error occurred', 'Dismiss', {
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Provider
|
||||
{ provide: ErrorHandler, useClass: GlobalErrorHandler }
|
||||
```
|
||||
@@ -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 <ttl>` with unique token, verify token before release. Or use Redlock. Naive `SETNX` without TTL = stuck tick on crash.
|
||||
- **Encounter resolver — pure function** in `libs/mission-logic`. Signature `resolveEncounter(survivor, perks, difficulty, seed) → EncounterResult`. Use seeded PRNG (`seedrandom`), not `Math.random()`. Store seed per tick in `mission_logs` for replay/debug.
|
||||
- `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.
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { MissionsModule } from './missions/missions.module';
|
||||
import { TickEngineModule } from './tick-engine/tick-engine.module';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
imports: [
|
||||
ScheduleModule.forRoot(),
|
||||
MissionsModule,
|
||||
TickEngineModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
72
apps/api/src/app/auth/twitch-jwt.guard.ts
Normal file
72
apps/api/src/app/auth/twitch-jwt.guard.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
CanActivate,
|
||||
createParamDecorator,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { createHmac } from 'crypto';
|
||||
|
||||
export interface TwitchJwtPayload {
|
||||
opaque_user_id: string;
|
||||
channel_id: string;
|
||||
role: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
interface HttpRequest {
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
twitchClaims?: TwitchJwtPayload;
|
||||
}
|
||||
|
||||
export const TwitchClaims = createParamDecorator(
|
||||
(_: unknown, ctx: ExecutionContext): TwitchJwtPayload => {
|
||||
const req = ctx.switchToHttp().getRequest<HttpRequest>();
|
||||
if (!req.twitchClaims) throw new UnauthorizedException('Missing claims');
|
||||
return req.twitchClaims;
|
||||
}
|
||||
);
|
||||
|
||||
@Injectable()
|
||||
export class TwitchJwtGuard implements CanActivate {
|
||||
canActivate(ctx: ExecutionContext): boolean {
|
||||
const req = ctx.switchToHttp().getRequest<HttpRequest>();
|
||||
const authHeader = req.headers['authorization'];
|
||||
const auth = Array.isArray(authHeader) ? authHeader[0] : authHeader;
|
||||
if (!auth?.startsWith('Bearer ')) throw new UnauthorizedException();
|
||||
|
||||
const token = auth.slice(7);
|
||||
req.twitchClaims = verifyAndDecode(token);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function verifyAndDecode(token: string): TwitchJwtPayload {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) throw new UnauthorizedException('Malformed JWT');
|
||||
|
||||
const [header, payloadB64, sig] = parts;
|
||||
const secret = process.env['TWITCH_EXTENSION_SECRET'];
|
||||
if (!secret) throw new UnauthorizedException('No extension secret configured');
|
||||
|
||||
// Twitch shared secret is base64-encoded; decode to raw bytes for HMAC.
|
||||
const secretBytes = Buffer.from(secret, 'base64');
|
||||
const expected = createHmac('sha256', secretBytes)
|
||||
.update(`${header}.${payloadB64}`)
|
||||
.digest('base64url');
|
||||
|
||||
if (expected !== sig) throw new UnauthorizedException('Invalid signature');
|
||||
|
||||
const raw = Buffer.from(
|
||||
payloadB64.replace(/-/g, '+').replace(/_/g, '/'),
|
||||
'base64'
|
||||
).toString('utf8');
|
||||
|
||||
const payload = JSON.parse(raw) as TwitchJwtPayload;
|
||||
|
||||
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
||||
throw new UnauthorizedException('Token expired');
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
171
apps/api/src/app/missions/encounter.service.ts
Normal file
171
apps/api/src/app/missions/encounter.service.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type {
|
||||
EncounterResult,
|
||||
Mission,
|
||||
MissionParticipant,
|
||||
MissionStateResponse,
|
||||
} from '@fog-explorer/api-interfaces';
|
||||
import {
|
||||
getEncounterById,
|
||||
getLibraryVersion,
|
||||
getRandomEncounterByTier,
|
||||
pickFlavor,
|
||||
} from '@fog-explorer/encounter-library';
|
||||
import { resolveEncounter } from '@fog-explorer/mission-logic';
|
||||
import seedrandom = require('seedrandom');
|
||||
import { GroupSynergyService } from './group-synergy.service';
|
||||
|
||||
const TICK_JITTER_MS = 5_000;
|
||||
const TICK_BASE_INTERVAL_MS = 60_000;
|
||||
const RECENT_LOG_MAX = 20;
|
||||
|
||||
@Injectable()
|
||||
export class EncounterService {
|
||||
private readonly logger = new Logger(EncounterService.name);
|
||||
|
||||
constructor(private readonly groupSynergy: GroupSynergyService) {}
|
||||
|
||||
/**
|
||||
* Resolves one tick for the given mission state.
|
||||
* Returns the updated state, or null if the mission ended.
|
||||
*/
|
||||
processTick(
|
||||
current: NonNullable<MissionStateResponse>
|
||||
): NonNullable<MissionStateResponse> {
|
||||
const { mission, survivors } = current;
|
||||
const tickIndex = mission.tickIndex + 1;
|
||||
const seed = buildSeed(mission.id, tickIndex);
|
||||
const rng = seedrandom(seed);
|
||||
|
||||
const tier = mission.difficulty as 1 | 2 | 3;
|
||||
const encounter =
|
||||
mission.encounterLibraryVersion === getLibraryVersion()
|
||||
? getRandomEncounterByTier(tier, rng)
|
||||
: getRandomEncounterByTier(tier, rng);
|
||||
|
||||
const groupModifiers = this.groupSynergy.collectGroupModifiers(survivors);
|
||||
const newLog: EncounterResult[] = [];
|
||||
let updatedSurvivors = survivors.map((s) => ({ ...s }));
|
||||
let updatedParticipants = mission.participants.map((p) => ({ ...p }));
|
||||
|
||||
for (const participant of mission.participants) {
|
||||
if (participant.state === 'sacrificed') continue;
|
||||
|
||||
const survivor = survivors.find((s) => s.id === participant.survivorId);
|
||||
if (!survivor) continue;
|
||||
|
||||
const augmentedPerks = this.groupSynergy.buildAugmentedPerks(
|
||||
survivor,
|
||||
groupModifiers
|
||||
);
|
||||
|
||||
const result = resolveEncounter({
|
||||
seed: `${seed}:${survivor.id}`,
|
||||
missionId: mission.id,
|
||||
tickIndex,
|
||||
difficulty: mission.difficulty,
|
||||
encounter,
|
||||
survivor: {
|
||||
id: survivor.id,
|
||||
state: survivor.state,
|
||||
stats: survivor.stats,
|
||||
perkSlots: augmentedPerks,
|
||||
hookCount: participant.hookCount,
|
||||
},
|
||||
});
|
||||
|
||||
const libEncounter = getEncounterById(encounter.key);
|
||||
const flavor = libEncounter
|
||||
? pickFlavor(libEncounter, { success: result.success }, rng)
|
||||
: result.logText;
|
||||
|
||||
newLog.push({ ...result, logText: flavor });
|
||||
|
||||
this.logger.log({
|
||||
message: 'tick resolved',
|
||||
missionId: mission.id,
|
||||
channelId: 'unknown',
|
||||
tickIndex,
|
||||
survivorId: survivor.id,
|
||||
encounterKey: encounter.key,
|
||||
success: result.success,
|
||||
seed: result.seed,
|
||||
});
|
||||
|
||||
const stateChange = result.survivorStateChange;
|
||||
if (stateChange) {
|
||||
updatedSurvivors = updatedSurvivors.map((s) =>
|
||||
s.id === survivor.id ? { ...s, state: stateChange.to } : s
|
||||
);
|
||||
updatedParticipants = updatedParticipants.map((p) => {
|
||||
if (p.survivorId !== survivor.id) return p;
|
||||
const hookCount =
|
||||
stateChange.to === 'downed'
|
||||
? Math.min(2, p.hookCount + 1)
|
||||
: p.hookCount;
|
||||
return { ...p, state: stateChange.to, hookCount };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updatedMission = buildUpdatedMission(
|
||||
mission,
|
||||
updatedParticipants,
|
||||
tickIndex
|
||||
);
|
||||
|
||||
const recentLog = [
|
||||
...newLog,
|
||||
...current.recentLog,
|
||||
].slice(0, RECENT_LOG_MAX);
|
||||
|
||||
return {
|
||||
mission: updatedMission,
|
||||
survivors: updatedSurvivors,
|
||||
recentLog,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildSeed(missionId: string, tickIndex: number): string {
|
||||
return `${missionId}:${tickIndex}`;
|
||||
}
|
||||
|
||||
function buildUpdatedMission(
|
||||
mission: Mission,
|
||||
participants: MissionParticipant[],
|
||||
tickIndex: number
|
||||
): Mission {
|
||||
const allSacrificed = participants.every(
|
||||
(p) => p.state === 'sacrificed'
|
||||
);
|
||||
const allEscaped = participants.every(
|
||||
(p) => p.state === 'active' || p.state === 'idle'
|
||||
);
|
||||
|
||||
let status = mission.status;
|
||||
let endedAt = mission.endedAt;
|
||||
|
||||
if (allSacrificed && mission.status === 'active') {
|
||||
status = 'sacrifice';
|
||||
endedAt = new Date().toISOString();
|
||||
} else if (allEscaped && tickIndex >= 10 && mission.status === 'active') {
|
||||
// Success after at least 10 ticks with all survivors still active
|
||||
status = 'success';
|
||||
endedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
const jitter = Math.floor(Math.random() * TICK_JITTER_MS);
|
||||
const nextTickAt = new Date(
|
||||
Date.now() + TICK_BASE_INTERVAL_MS + jitter
|
||||
).toISOString();
|
||||
|
||||
return {
|
||||
...mission,
|
||||
status,
|
||||
endedAt,
|
||||
participants,
|
||||
tickIndex,
|
||||
nextTickAt,
|
||||
};
|
||||
}
|
||||
63
apps/api/src/app/missions/group-synergy.service.ts
Normal file
63
apps/api/src/app/missions/group-synergy.service.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Perk, PerkModifier, Survivor } from '@fog-explorer/api-interfaces';
|
||||
|
||||
export interface SynergyModifier extends PerkModifier {
|
||||
sourceKey: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GroupSynergyService {
|
||||
/**
|
||||
* Collect every perk modifier from all active survivors in a SWF group,
|
||||
* deduplicating by perkKey so one survivor's perk doesn't stack with itself
|
||||
* if they appear multiple times.
|
||||
*/
|
||||
collectGroupModifiers(survivors: Survivor[]): SynergyModifier[] {
|
||||
const seen = new Set<string>();
|
||||
const modifiers: SynergyModifier[] = [];
|
||||
|
||||
for (const survivor of survivors) {
|
||||
if (survivor.state === 'sacrificed') continue;
|
||||
|
||||
for (const perk of survivor.perkSlots) {
|
||||
const dedupKey = `${survivor.id}:${perk.key}`;
|
||||
if (seen.has(dedupKey)) continue;
|
||||
seen.add(dedupKey);
|
||||
|
||||
for (const mod of perk.modifiers) {
|
||||
modifiers.push({ ...mod, sourceKey: perk.key });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return modifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an augmented perk list for a specific survivor that includes
|
||||
* group-wide synergy perks from other survivors as phantom perk entries.
|
||||
* Only `successChance` modifiers propagate group-wide; stat modifiers stay personal.
|
||||
*/
|
||||
buildAugmentedPerks(
|
||||
targetSurvivor: Survivor,
|
||||
groupModifiers: SynergyModifier[]
|
||||
): Perk[] {
|
||||
const personal = targetSurvivor.perkSlots;
|
||||
const personalKeys = new Set(personal.map((p) => p.key));
|
||||
|
||||
const synergies = groupModifiers
|
||||
.filter((m) => m.target === 'successChance' && !personalKeys.has(m.sourceKey))
|
||||
.map(
|
||||
(m): Perk => ({
|
||||
id: '00000000-0000-4000-a000-000000000000',
|
||||
key: `synergy:${m.sourceKey}`,
|
||||
name: `Group: ${m.sourceKey}`,
|
||||
description: '',
|
||||
tags: [],
|
||||
modifiers: [{ target: m.target, type: m.type, amount: m.amount, condition: m.condition }],
|
||||
})
|
||||
);
|
||||
|
||||
return [...personal, ...synergies];
|
||||
}
|
||||
}
|
||||
87
apps/api/src/app/missions/mission-store.service.ts
Normal file
87
apps/api/src/app/missions/mission-store.service.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Redis } from 'ioredis';
|
||||
import type { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
||||
import { REDIS_CLIENT } from '../redis/redis.module';
|
||||
|
||||
const ACTIVE_MISSION_PREFIX = 'active_mission:';
|
||||
const CHANNEL_MISSION_KEY = (channelId: string) => `channel_mission:${channelId}`;
|
||||
const TICK_QUEUE_KEY = 'missions:tick_queue';
|
||||
const LOCK_PREFIX = 'tick_lock:';
|
||||
const LOCK_TTL_MS = 30_000;
|
||||
|
||||
@Injectable()
|
||||
export class MissionStoreService {
|
||||
constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {}
|
||||
|
||||
async getActiveMission(missionId: string): Promise<MissionStateResponse | null> {
|
||||
const raw = await this.redis.get(`${ACTIVE_MISSION_PREFIX}${missionId}`);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as MissionStateResponse;
|
||||
}
|
||||
|
||||
async setActiveMission(state: NonNullable<MissionStateResponse>): Promise<void> {
|
||||
const missionId = state.mission.id;
|
||||
const key = `${ACTIVE_MISSION_PREFIX}${missionId}`;
|
||||
await this.redis.set(key, JSON.stringify(state));
|
||||
}
|
||||
|
||||
async deleteActiveMission(missionId: string): Promise<void> {
|
||||
await this.redis.del(`${ACTIVE_MISSION_PREFIX}${missionId}`);
|
||||
}
|
||||
|
||||
async getChannelMissionId(channelId: string): Promise<string | null> {
|
||||
return this.redis.get(CHANNEL_MISSION_KEY(channelId));
|
||||
}
|
||||
|
||||
async setChannelMissionId(channelId: string, missionId: string): Promise<void> {
|
||||
await this.redis.set(CHANNEL_MISSION_KEY(channelId), missionId);
|
||||
}
|
||||
|
||||
async clearChannelMission(channelId: string): Promise<void> {
|
||||
await this.redis.del(CHANNEL_MISSION_KEY(channelId));
|
||||
}
|
||||
|
||||
async getStateForChannel(channelId: string): Promise<MissionStateResponse> {
|
||||
const missionId = await this.getChannelMissionId(channelId);
|
||||
if (!missionId) return null;
|
||||
return this.getActiveMission(missionId);
|
||||
}
|
||||
|
||||
// Sorted-set tick queue — score is the nextTickAt Unix ms timestamp.
|
||||
async scheduleTick(missionId: string, nextTickAtMs: number): Promise<void> {
|
||||
await this.redis.zadd(TICK_QUEUE_KEY, nextTickAtMs, missionId);
|
||||
}
|
||||
|
||||
async removeMissionFromQueue(missionId: string): Promise<void> {
|
||||
await this.redis.zrem(TICK_QUEUE_KEY, missionId);
|
||||
}
|
||||
|
||||
async getDueMissionIds(nowMs: number): Promise<string[]> {
|
||||
return this.redis.zrangebyscore(TICK_QUEUE_KEY, '-inf', nowMs);
|
||||
}
|
||||
|
||||
// Distributed lock — SET NX PX with a unique token.
|
||||
async acquireLock(missionId: string): Promise<string | null> {
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
||||
const result = await this.redis.set(
|
||||
`${LOCK_PREFIX}${missionId}`,
|
||||
token,
|
||||
'PX',
|
||||
LOCK_TTL_MS,
|
||||
'NX'
|
||||
);
|
||||
return result === 'OK' ? token : null;
|
||||
}
|
||||
|
||||
async releaseLock(missionId: string, token: string): Promise<void> {
|
||||
// Atomic check-and-delete via Lua to avoid releasing another owner's lock.
|
||||
const script = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("del", KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`;
|
||||
await this.redis.eval(script, 1, `${LOCK_PREFIX}${missionId}`, token);
|
||||
}
|
||||
}
|
||||
48
apps/api/src/app/missions/missions.controller.ts
Normal file
48
apps/api/src/app/missions/missions.controller.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
MissionStateResponse,
|
||||
MissionStateResponseSchema,
|
||||
StartMissionRequestSchema,
|
||||
} from '@fog-explorer/api-interfaces';
|
||||
import { TwitchClaims, TwitchJwtGuard, TwitchJwtPayload } from '../auth/twitch-jwt.guard';
|
||||
import { MissionStoreService } from './mission-store.service';
|
||||
import { MissionsService } from './missions.service';
|
||||
|
||||
@Controller('missions')
|
||||
@UseGuards(TwitchJwtGuard)
|
||||
export class MissionsController {
|
||||
constructor(
|
||||
private readonly store: MissionStoreService,
|
||||
private readonly missions: MissionsService
|
||||
) {}
|
||||
|
||||
@Get('state')
|
||||
async getState(
|
||||
@TwitchClaims() claims: TwitchJwtPayload
|
||||
): Promise<MissionStateResponse> {
|
||||
const state = await this.store.getStateForChannel(claims.channel_id);
|
||||
return MissionStateResponseSchema.parse(state);
|
||||
}
|
||||
|
||||
@Post('start')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async startMission(
|
||||
@TwitchClaims() claims: TwitchJwtPayload,
|
||||
@Body() body: unknown
|
||||
): Promise<MissionStateResponse> {
|
||||
if (!claims.opaque_user_id.startsWith('U')) {
|
||||
throw new NotFoundException('Anonymous viewers cannot start missions');
|
||||
}
|
||||
const { difficulty } = StartMissionRequestSchema.parse(body);
|
||||
return this.missions.startMission(claims, difficulty);
|
||||
}
|
||||
}
|
||||
20
apps/api/src/app/missions/missions.module.ts
Normal file
20
apps/api/src/app/missions/missions.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RedisModule } from '../redis/redis.module';
|
||||
import { EncounterService } from './encounter.service';
|
||||
import { GroupSynergyService } from './group-synergy.service';
|
||||
import { MissionStoreService } from './mission-store.service';
|
||||
import { MissionsController } from './missions.controller';
|
||||
import { MissionsService } from './missions.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisModule],
|
||||
controllers: [MissionsController],
|
||||
providers: [
|
||||
MissionStoreService,
|
||||
MissionsService,
|
||||
EncounterService,
|
||||
GroupSynergyService,
|
||||
],
|
||||
exports: [MissionStoreService, EncounterService],
|
||||
})
|
||||
export class MissionsModule {}
|
||||
75
apps/api/src/app/missions/missions.service.ts
Normal file
75
apps/api/src/app/missions/missions.service.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
Mission,
|
||||
MissionStateResponse,
|
||||
Survivor,
|
||||
SurvivorStats,
|
||||
} from '@fog-explorer/api-interfaces';
|
||||
import { getLibraryVersion } from '@fog-explorer/encounter-library';
|
||||
import { TwitchJwtPayload } from '../auth/twitch-jwt.guard';
|
||||
import { MissionStoreService } from './mission-store.service';
|
||||
|
||||
const TICK_BASE_INTERVAL_MS = 60_000;
|
||||
const TICK_JITTER_MS = 5_000;
|
||||
|
||||
@Injectable()
|
||||
export class MissionsService {
|
||||
constructor(private readonly store: MissionStoreService) {}
|
||||
|
||||
async startMission(
|
||||
claims: TwitchJwtPayload,
|
||||
difficulty: number
|
||||
): Promise<NonNullable<MissionStateResponse>> {
|
||||
const missionId = crypto.randomUUID();
|
||||
const survivorId = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const jitter = Math.floor(Math.random() * TICK_JITTER_MS);
|
||||
const nextTickAt = new Date(Date.now() + TICK_BASE_INTERVAL_MS + jitter).toISOString();
|
||||
|
||||
const stats: SurvivorStats = defaultStats();
|
||||
|
||||
const survivor: Survivor = {
|
||||
id: survivorId,
|
||||
opaqueUserId: claims.opaque_user_id,
|
||||
channelId: claims.channel_id,
|
||||
name: defaultName(claims.opaque_user_id),
|
||||
state: 'active',
|
||||
stats,
|
||||
perkSlots: [],
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
const mission: Mission = {
|
||||
id: missionId,
|
||||
groupId: null,
|
||||
participants: [{ survivorId, state: 'active', hookCount: 0 }],
|
||||
difficulty,
|
||||
status: 'active',
|
||||
encounterLibraryVersion: getLibraryVersion(),
|
||||
nextTickAt,
|
||||
tickIndex: 0,
|
||||
startedAt: now,
|
||||
endedAt: null,
|
||||
};
|
||||
|
||||
const state: NonNullable<MissionStateResponse> = {
|
||||
mission,
|
||||
survivors: [survivor],
|
||||
recentLog: [],
|
||||
};
|
||||
|
||||
await this.store.setActiveMission(state);
|
||||
await this.store.setChannelMissionId(claims.channel_id, missionId);
|
||||
await this.store.scheduleTick(missionId, new Date(nextTickAt).getTime());
|
||||
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultStats(): SurvivorStats {
|
||||
return { objectives: 5, survival: 5, altruism: 5 };
|
||||
}
|
||||
|
||||
function defaultName(opaqueUserId: string): string {
|
||||
return `Survivor ${opaqueUserId.slice(-4)}`;
|
||||
}
|
||||
20
apps/api/src/app/redis/redis.module.ts
Normal file
20
apps/api/src/app/redis/redis.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Redis } from 'ioredis';
|
||||
|
||||
export const REDIS_CLIENT = Symbol('REDIS_CLIENT');
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: REDIS_CLIENT,
|
||||
useFactory: () =>
|
||||
new Redis({
|
||||
host: process.env['REDIS_HOST'] ?? 'localhost',
|
||||
port: Number(process.env['REDIS_PORT'] ?? 6379),
|
||||
lazyConnect: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
exports: [REDIS_CLIENT],
|
||||
})
|
||||
export class RedisModule {}
|
||||
9
apps/api/src/app/tick-engine/tick-engine.module.ts
Normal file
9
apps/api/src/app/tick-engine/tick-engine.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MissionsModule } from '../missions/missions.module';
|
||||
import { TickService } from './tick.service';
|
||||
|
||||
@Module({
|
||||
imports: [MissionsModule],
|
||||
providers: [TickService],
|
||||
})
|
||||
export class TickEngineModule {}
|
||||
61
apps/api/src/app/tick-engine/tick.service.ts
Normal file
61
apps/api/src/app/tick-engine/tick.service.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { MissionStoreService } from '../missions/mission-store.service';
|
||||
import { EncounterService } from '../missions/encounter.service';
|
||||
|
||||
@Injectable()
|
||||
export class TickService {
|
||||
private readonly logger = new Logger(TickService.name);
|
||||
|
||||
constructor(
|
||||
private readonly store: MissionStoreService,
|
||||
private readonly encounters: EncounterService
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_10_SECONDS)
|
||||
async heartbeat(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const dueMissionIds = await this.store.getDueMissionIds(now);
|
||||
|
||||
await Promise.allSettled(
|
||||
dueMissionIds.map((id) => this.processMission(id))
|
||||
);
|
||||
}
|
||||
|
||||
private async processMission(missionId: string): Promise<void> {
|
||||
const token = await this.store.acquireLock(missionId);
|
||||
if (!token) return; // Another worker has the lock
|
||||
|
||||
try {
|
||||
const state = await this.store.getActiveMission(missionId);
|
||||
if (!state || state.mission.status !== 'active') {
|
||||
await this.store.removeMissionFromQueue(missionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = this.encounters.processTick(state);
|
||||
|
||||
await this.store.setActiveMission(updated);
|
||||
|
||||
if (
|
||||
updated.mission.status === 'active' ||
|
||||
updated.mission.status === 'lobby'
|
||||
) {
|
||||
const nextMs = new Date(updated.mission.nextTickAt).getTime();
|
||||
await this.store.scheduleTick(missionId, nextMs);
|
||||
} else {
|
||||
await this.store.removeMissionFromQueue(missionId);
|
||||
this.logger.log({
|
||||
message: 'mission ended',
|
||||
missionId,
|
||||
status: updated.mission.status,
|
||||
tickIndex: updated.mission.tickIndex,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error({ message: 'tick failed', missionId, err });
|
||||
} finally {
|
||||
await this.store.releaseLock(missionId, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ async function bootstrap() {
|
||||
const globalPrefix = 'api';
|
||||
app.setGlobalPrefix(globalPrefix);
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
Logger.log(
|
||||
`🚀 Application is running on: http://localhost:${port}/${globalPrefix}`,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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: `<app-panel-shell />`,
|
||||
})
|
||||
export class App {
|
||||
protected title = 'overlay';
|
||||
}
|
||||
export class App {}
|
||||
|
||||
68
apps/overlay/src/app/ebs/auth.interceptor.spec.ts
Normal file
68
apps/overlay/src/app/ebs/auth.interceptor.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting,
|
||||
} from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TwitchAuthService } from '../twitch/twitch-auth.service';
|
||||
import { authInterceptor } from './auth.interceptor';
|
||||
import { EbsApiService, EBS_BASE_URL } from './ebs-api.service';
|
||||
|
||||
function makeJwt(): string {
|
||||
const payload = btoa(JSON.stringify({ opaque_user_id: 'U123', channel_id: 'ch1', role: 'viewer', exp: 9999999999 }))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
return `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`;
|
||||
}
|
||||
|
||||
describe('authInterceptor', () => {
|
||||
let controller: HttpTestingController;
|
||||
let authService: TwitchAuthService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: EBS_BASE_URL, useValue: 'https://test.local' },
|
||||
],
|
||||
});
|
||||
controller = TestBed.inject(HttpTestingController);
|
||||
authService = TestBed.inject(TwitchAuthService);
|
||||
});
|
||||
|
||||
afterEach(() => controller.verify());
|
||||
|
||||
it('omits Authorization header when not yet authorized', () => {
|
||||
const svc = TestBed.inject(EbsApiService);
|
||||
svc.getMissionState().subscribe();
|
||||
|
||||
const req = controller.expectOne('https://test.local/missions/state');
|
||||
expect(req.request.headers.has('Authorization')).toBe(false);
|
||||
req.flush(null);
|
||||
});
|
||||
|
||||
it('attaches Bearer token after onAuthorized fires', () => {
|
||||
const jwt = makeJwt();
|
||||
(window as Window & { Twitch?: unknown }).Twitch = {
|
||||
ext: {
|
||||
onAuthorized: (cb: (a: TwitchAuth) => void) =>
|
||||
cb({ channelId: 'ch1', clientId: 'c1', token: jwt, userId: 'U123' }),
|
||||
onContext: () => undefined,
|
||||
onVisibilityChanged: () => undefined,
|
||||
listen: () => undefined,
|
||||
unlisten: () => undefined,
|
||||
send: () => undefined,
|
||||
},
|
||||
};
|
||||
authService.init();
|
||||
|
||||
const svc = TestBed.inject(EbsApiService);
|
||||
svc.getMissionState().subscribe();
|
||||
|
||||
const req = controller.expectOne('https://test.local/missions/state');
|
||||
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${jwt}`);
|
||||
req.flush(null);
|
||||
|
||||
delete (window as Window & { Twitch?: unknown }).Twitch;
|
||||
});
|
||||
});
|
||||
9
apps/overlay/src/app/ebs/auth.interceptor.ts
Normal file
9
apps/overlay/src/app/ebs/auth.interceptor.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { TwitchAuthService } from '../twitch/twitch-auth.service';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const token = inject(TwitchAuthService).auth()?.token;
|
||||
if (!token) return next(req);
|
||||
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
|
||||
};
|
||||
78
apps/overlay/src/app/ebs/ebs-api.service.spec.ts
Normal file
78
apps/overlay/src/app/ebs/ebs-api.service.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting,
|
||||
} from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { EbsApiService, EBS_BASE_URL } from './ebs-api.service';
|
||||
|
||||
const BASE = 'https://test.local';
|
||||
|
||||
describe('EbsApiService', () => {
|
||||
let service: EbsApiService;
|
||||
let controller: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: EBS_BASE_URL, useValue: BASE },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(EbsApiService);
|
||||
controller = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => controller.verify());
|
||||
|
||||
describe('getMissionState', () => {
|
||||
it('GETs /missions/state and returns null for no active mission', () => {
|
||||
let result: unknown;
|
||||
service.getMissionState().subscribe((v) => (result = v));
|
||||
|
||||
controller.expectOne(`${BASE}/missions/state`).flush(null);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('throws ZodError when server returns invalid shape', () => {
|
||||
let error: unknown;
|
||||
service.getMissionState().subscribe({ error: (e) => (error = e) });
|
||||
|
||||
controller.expectOne(`${BASE}/missions/state`).flush({ bad: 'data' });
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startMission', () => {
|
||||
it('POSTs to /missions/start with difficulty', () => {
|
||||
service.startMission({ difficulty: 2 }).subscribe({ error: () => undefined });
|
||||
|
||||
const req = controller.expectOne(`${BASE}/missions/start`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual({ difficulty: 2 });
|
||||
req.flush({ bad: 'shape' });
|
||||
});
|
||||
|
||||
it('throws ZodError for invalid difficulty before sending request', () => {
|
||||
expect(() => service.startMission({ difficulty: 99 })).toThrow();
|
||||
controller.expectNone(`${BASE}/missions/start`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('choosePerk', () => {
|
||||
it('POSTs to /missions/choose-perk with perkKey', () => {
|
||||
service.choosePerk({ perkKey: 'iron_will' }).subscribe({ error: () => undefined });
|
||||
|
||||
const req = controller.expectOne(`${BASE}/missions/choose-perk`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual({ perkKey: 'iron_will' });
|
||||
req.flush({ bad: 'shape' });
|
||||
});
|
||||
|
||||
it('throws ZodError for empty perkKey before sending request', () => {
|
||||
expect(() => service.choosePerk({ perkKey: '' })).toThrow();
|
||||
controller.expectNone(`${BASE}/missions/choose-perk`);
|
||||
});
|
||||
});
|
||||
});
|
||||
41
apps/overlay/src/app/ebs/ebs-api.service.ts
Normal file
41
apps/overlay/src/app/ebs/ebs-api.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import {
|
||||
ChoosePerkRequest,
|
||||
ChoosePerkRequestSchema,
|
||||
MissionStateResponse,
|
||||
MissionStateResponseSchema,
|
||||
MissionSchema,
|
||||
StartMissionRequest,
|
||||
StartMissionRequestSchema,
|
||||
SurvivorSchema,
|
||||
} from '@fog-explorer/api-interfaces';
|
||||
import { map, Observable } from 'rxjs';
|
||||
|
||||
export const EBS_BASE_URL = new InjectionToken<string>('EBS_BASE_URL');
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EbsApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = inject(EBS_BASE_URL, { optional: true }) ?? 'https://localhost:3000';
|
||||
|
||||
getMissionState(): Observable<MissionStateResponse> {
|
||||
return this.http
|
||||
.get(`${this.baseUrl}/missions/state`)
|
||||
.pipe(map((body) => MissionStateResponseSchema.parse(body)));
|
||||
}
|
||||
|
||||
startMission(req: StartMissionRequest) {
|
||||
StartMissionRequestSchema.parse(req);
|
||||
return this.http
|
||||
.post(`${this.baseUrl}/missions/start`, req)
|
||||
.pipe(map((body) => MissionSchema.parse(body)));
|
||||
}
|
||||
|
||||
choosePerk(req: ChoosePerkRequest) {
|
||||
ChoosePerkRequestSchema.parse(req);
|
||||
return this.http
|
||||
.post(`${this.baseUrl}/missions/choose-perk`, req)
|
||||
.pipe(map((body) => SurvivorSchema.parse(body)));
|
||||
}
|
||||
}
|
||||
159
apps/overlay/src/app/mission/mission-state.store.spec.ts
Normal file
159
apps/overlay/src/app/mission/mission-state.store.spec.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting,
|
||||
} from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { EBS_BASE_URL } from '../ebs/ebs-api.service';
|
||||
import { TwitchAuthService } from '../twitch/twitch-auth.service';
|
||||
import { MissionStateStore } from './mission-state.store';
|
||||
|
||||
const BASE = 'https://test.local';
|
||||
const STATE_URL = `${BASE}/missions/state`;
|
||||
|
||||
function makeJwt(): string {
|
||||
const payload = btoa(
|
||||
JSON.stringify({ opaque_user_id: 'U123', channel_id: 'ch1', role: 'viewer', exp: 9999999999 })
|
||||
).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
return `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`;
|
||||
}
|
||||
|
||||
describe('MissionStateStore', () => {
|
||||
let store: MissionStateStore;
|
||||
let authService: TwitchAuthService;
|
||||
let controller: HttpTestingController;
|
||||
let visibilityCallback: ((visible: boolean, ctx: TwitchContext) => void) | undefined;
|
||||
let pubsubCallbacks: Record<string, (t: string, ct: string, msg: string) => void>;
|
||||
|
||||
function mountTwitchExt(alreadyAuthorized = true) {
|
||||
const jwt = makeJwt();
|
||||
pubsubCallbacks = {};
|
||||
(window as Window & { Twitch?: unknown }).Twitch = {
|
||||
ext: {
|
||||
onAuthorized: (cb: (a: TwitchAuth) => void) => {
|
||||
if (alreadyAuthorized) {
|
||||
cb({ channelId: 'ch1', clientId: 'c1', token: jwt, userId: 'U123' });
|
||||
}
|
||||
},
|
||||
onContext: () => undefined,
|
||||
onVisibilityChanged: (cb: (v: boolean, ctx: TwitchContext) => void) => {
|
||||
visibilityCallback = cb;
|
||||
},
|
||||
listen: (target: string, cb: (t: string, ct: string, msg: string) => void) => {
|
||||
pubsubCallbacks[target] = cb;
|
||||
},
|
||||
unlisten: () => undefined,
|
||||
send: () => undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function removeTwitchExt() {
|
||||
delete (window as Window & { Twitch?: unknown }).Twitch;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
visibilityCallback = undefined;
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: EBS_BASE_URL, useValue: BASE },
|
||||
],
|
||||
});
|
||||
store = TestBed.inject(MissionStateStore);
|
||||
authService = TestBed.inject(TwitchAuthService);
|
||||
controller = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
controller.verify();
|
||||
removeTwitchExt();
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('does not fetch state when not yet authorized', () => {
|
||||
mountTwitchExt(false);
|
||||
authService.init();
|
||||
store.init();
|
||||
|
||||
controller.expectNone(STATE_URL);
|
||||
});
|
||||
|
||||
it('fetches state immediately when authorized', () => {
|
||||
mountTwitchExt();
|
||||
authService.init();
|
||||
store.init();
|
||||
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
expect(store.state()).toBeNull();
|
||||
});
|
||||
|
||||
it('sets loading true during fetch and false after', () => {
|
||||
mountTwitchExt();
|
||||
authService.init();
|
||||
store.init();
|
||||
|
||||
expect(store.loading()).toBe(true);
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
expect(store.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('sets error signal when request fails', () => {
|
||||
mountTwitchExt();
|
||||
authService.init();
|
||||
store.init();
|
||||
|
||||
controller.expectOne(STATE_URL).flush('server error', {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
});
|
||||
|
||||
expect(store.error()).toBeTruthy();
|
||||
expect(store.loading()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PubSub', () => {
|
||||
it('re-fetches when a broadcast arrives while visible', () => {
|
||||
mountTwitchExt();
|
||||
authService.init();
|
||||
store.init();
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
|
||||
pubsubCallbacks['broadcast']('broadcast', 'json', '{}');
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
});
|
||||
|
||||
it('ignores broadcast when overlay is hidden', () => {
|
||||
mountTwitchExt();
|
||||
authService.init();
|
||||
store.init();
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
|
||||
visibilityCallback!(false, {} as TwitchContext);
|
||||
pubsubCallbacks['broadcast']('broadcast', 'json', '{}');
|
||||
|
||||
controller.expectNone(STATE_URL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('re-fetches state when called', () => {
|
||||
mountTwitchExt();
|
||||
authService.init();
|
||||
store.init();
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
|
||||
store.refresh();
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
});
|
||||
|
||||
it('is a no-op when not yet authorized', () => {
|
||||
mountTwitchExt(false);
|
||||
authService.init();
|
||||
store.refresh();
|
||||
controller.expectNone(STATE_URL);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
apps/overlay/src/app/mission/mission-state.store.ts
Normal file
58
apps/overlay/src/app/mission/mission-state.store.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { inject, Injectable, DestroyRef, signal } from '@angular/core';
|
||||
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
||||
import { EbsApiService } from '../ebs/ebs-api.service';
|
||||
import { TwitchAuthService } from '../twitch/twitch-auth.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MissionStateStore {
|
||||
private readonly ebs = inject(EbsApiService);
|
||||
private readonly authService = inject(TwitchAuthService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
private readonly _state = signal<MissionStateResponse>(null);
|
||||
private readonly _loading = signal(false);
|
||||
private readonly _error = signal<unknown>(null);
|
||||
|
||||
readonly state = this._state.asReadonly();
|
||||
readonly loading = this._loading.asReadonly();
|
||||
readonly error = this._error.asReadonly();
|
||||
|
||||
init(): void {
|
||||
this.fetchState();
|
||||
this.subscribePubSub();
|
||||
}
|
||||
|
||||
/** Call when the overlay becomes visible after being hidden to reconcile missed ticks. */
|
||||
refresh(): void {
|
||||
this.fetchState();
|
||||
}
|
||||
|
||||
private fetchState(): void {
|
||||
if (!this.authService.auth()) return;
|
||||
|
||||
this._loading.set(true);
|
||||
this.ebs.getMissionState().subscribe({
|
||||
next: (state) => {
|
||||
this._state.set(state);
|
||||
this._loading.set(false);
|
||||
this._error.set(null);
|
||||
},
|
||||
error: (err) => {
|
||||
this._loading.set(false);
|
||||
this._error.set(err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private subscribePubSub(): void {
|
||||
if (!window.Twitch?.ext) return;
|
||||
|
||||
const listener = (_target: string, _contentType: string, _message: string): void => {
|
||||
if (!this.authService.isVisible()) return;
|
||||
this.fetchState();
|
||||
};
|
||||
|
||||
window.Twitch.ext.listen('broadcast', listener);
|
||||
this.destroyRef.onDestroy(() => window.Twitch?.ext.unlisten('broadcast', listener));
|
||||
}
|
||||
}
|
||||
52
apps/overlay/src/app/panel/ambient-event.component.ts
Normal file
52
apps/overlay/src/app/panel/ambient-event.component.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
|
||||
export interface AmbientEventData {
|
||||
type: 'injury' | 'sacrifice' | 'mission-complete' | 'perk-acquired';
|
||||
description: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-ambient-event',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
.panel {
|
||||
width: 290px;
|
||||
height: 92px;
|
||||
background: rgba(15, 18, 22, 0.88);
|
||||
padding: 12px 16px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.event-type {
|
||||
font-family: sans-serif;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #6a7080;
|
||||
}
|
||||
.event-type.injury, .event-type.downed { color: #B8842E; }
|
||||
.event-type.sacrifice { color: #C03A3A; }
|
||||
.event-type.mission-complete { color: #E8A547; }
|
||||
.event-desc {
|
||||
font-family: 'JetBrains Mono', 'IBM Plex Mono', monospace;
|
||||
font-size: 13px;
|
||||
color: #c8ccd0;
|
||||
}
|
||||
`],
|
||||
template: `
|
||||
<div class="panel" (click)="dismissed.emit()">
|
||||
<span class="event-type" [class]="event().type">{{ event().type }}</span>
|
||||
<span class="event-desc">{{ event().description }}</span>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class AmbientEventComponent {
|
||||
event = input.required<AmbientEventData>();
|
||||
dismissed = output<void>();
|
||||
}
|
||||
129
apps/overlay/src/app/panel/expanded-panel.component.ts
Normal file
129
apps/overlay/src/app/panel/expanded-panel.component.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-expanded-panel',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
.panel {
|
||||
width: 320px;
|
||||
height: 440px;
|
||||
background: rgba(15, 18, 22, 0.95);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6a7080;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.survivor-name {
|
||||
font-family: 'Cormorant', serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #e0e4e8;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.survivor-state {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #E8A547;
|
||||
}
|
||||
.survivor-state.injured, .survivor-state.downed { color: #B8842E; }
|
||||
.survivor-state.sacrificed { color: #C03A3A; }
|
||||
.mission-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.difficulty { color: #E8A547; letter-spacing: 0.1em; font-size: 12px; }
|
||||
.tick { font-family: monospace; font-size: 11px; color: #6a7080; margin-left: auto; }
|
||||
.log {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.log-entry {
|
||||
font-family: 'JetBrains Mono', 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: #c8ccd0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.perk-slots {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.perk {
|
||||
font-size: 10px;
|
||||
background: rgba(232, 165, 71, 0.12);
|
||||
color: #E8A547;
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.perk-empty { font-size: 11px; color: #6a7080; font-style: italic; }
|
||||
`],
|
||||
template: `
|
||||
<div class="panel" style="position:relative">
|
||||
<button class="close-btn" (click)="close.emit()">✕</button>
|
||||
@if (survivor(); as s) {
|
||||
<div>
|
||||
<div class="survivor-name">{{ s.name }}</div>
|
||||
<div class="survivor-state" [class]="s.state">{{ s.state }}</div>
|
||||
<div class="perk-slots">
|
||||
@for (perk of s.perkSlots; track perk.id) {
|
||||
<span class="perk">{{ perk.name }}</span>
|
||||
} @empty {
|
||||
<span class="perk-empty">No perks equipped</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (mission(); as m) {
|
||||
<div class="mission-strip">
|
||||
<span class="difficulty">{{ difficultyGlyphs() }}</span>
|
||||
<span class="tick">T+{{ m.tickIndex }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="log">
|
||||
@for (entry of recentLog(); track entry.tickIndex) {
|
||||
<div class="log-entry">{{ entry.logText }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ExpandedPanelComponent {
|
||||
missionState = input.required<NonNullable<MissionStateResponse>>();
|
||||
close = output<void>();
|
||||
|
||||
protected mission = computed(() => this.missionState().mission);
|
||||
protected survivor = computed(() => this.missionState().survivors[0] ?? null);
|
||||
protected recentLog = computed(() => [...this.missionState().recentLog].reverse());
|
||||
protected difficultyGlyphs = computed(() =>
|
||||
'◆'.repeat(this.missionState().mission.difficulty)
|
||||
);
|
||||
}
|
||||
78
apps/overlay/src/app/panel/minimised-panel.component.ts
Normal file
78
apps/overlay/src/app/panel/minimised-panel.component.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-minimised-panel',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
.panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 290px;
|
||||
height: 56px;
|
||||
background: rgba(15, 18, 22, 0.88);
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.lantern {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #1a1e24;
|
||||
border: 3px solid #E8A547;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.lantern.injured { border-color: #B8842E; }
|
||||
.lantern.downed, .lantern.sacrificed { border-color: #C03A3A; }
|
||||
.ticker {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.log-line {
|
||||
display: block;
|
||||
font-family: 'JetBrains Mono', 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: #c8ccd0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.idle { color: #6a7080; font-style: italic; }
|
||||
`],
|
||||
template: `
|
||||
<div class="panel">
|
||||
<div class="lantern" [class]="survivorState()" (click)="lanternClick.emit()"></div>
|
||||
<div class="ticker">
|
||||
@if (latestLogLine(); as line) {
|
||||
<span class="log-line">{{ line }}</span>
|
||||
} @else {
|
||||
<span class="log-line idle">The fog stirs…</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class MinimisedPanelComponent {
|
||||
missionState = input.required<NonNullable<MissionStateResponse>>();
|
||||
lanternClick = output<void>();
|
||||
|
||||
protected survivorState = computed(
|
||||
() => this.missionState().survivors[0]?.state ?? 'idle'
|
||||
);
|
||||
|
||||
protected latestLogLine = computed(() => {
|
||||
const log = this.missionState().recentLog;
|
||||
return log.length ? log[log.length - 1].logText : null;
|
||||
});
|
||||
}
|
||||
253
apps/overlay/src/app/panel/panel-shell.component.spec.ts
Normal file
253
apps/overlay/src/app/panel/panel-shell.component.spec.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting,
|
||||
} from '@angular/common/http/testing';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MissionSchema, MissionStateResponse, SurvivorSchema } from '@fog-explorer/api-interfaces';
|
||||
import { EBS_BASE_URL } from '../ebs/ebs-api.service';
|
||||
import { TwitchAuthService } from '../twitch/twitch-auth.service';
|
||||
import { AmbientEventData } from './ambient-event.component';
|
||||
import { PanelShellComponent } from './panel-shell.component';
|
||||
|
||||
// Stub child components using @Input()/@Output() — JIT doesn't recognise signal inputs
|
||||
@Component({ selector: 'app-minimised-panel', standalone: true, template: '' })
|
||||
class MinimisedPanelStub {
|
||||
@Input() missionState!: NonNullable<MissionStateResponse>;
|
||||
@Output() lanternClick = new EventEmitter<void>();
|
||||
}
|
||||
|
||||
@Component({ selector: 'app-ambient-event', standalone: true, template: '' })
|
||||
class AmbientEventStub {
|
||||
@Input() event!: AmbientEventData;
|
||||
@Output() dismissed = new EventEmitter<void>();
|
||||
}
|
||||
|
||||
@Component({ selector: 'app-expanded-panel', standalone: true, template: '' })
|
||||
class ExpandedPanelStub {
|
||||
@Input() missionState!: NonNullable<MissionStateResponse>;
|
||||
@Output() close = new EventEmitter<void>();
|
||||
}
|
||||
|
||||
const BASE = 'https://test.local';
|
||||
const STATE_URL = `${BASE}/missions/state`;
|
||||
|
||||
function makeJwt(): string {
|
||||
const payload = btoa(
|
||||
JSON.stringify({ opaque_user_id: 'U123', channel_id: 'ch1', role: 'viewer', exp: 9999999999 })
|
||||
).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
return `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`;
|
||||
}
|
||||
|
||||
function makeAnonymousJwt(): string {
|
||||
const payload = btoa(
|
||||
JSON.stringify({ opaque_user_id: 'Aanon', channel_id: 'ch1', role: 'viewer', exp: 9999999999 })
|
||||
).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
return `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`;
|
||||
}
|
||||
|
||||
const SURVIVOR = SurvivorSchema.parse({
|
||||
id: 'a1b2c3d4-e5f6-4789-a012-b3c4d5e6f701',
|
||||
opaqueUserId: 'U123',
|
||||
channelId: 'ch1',
|
||||
name: 'Hana',
|
||||
state: 'active',
|
||||
stats: { objectives: 5, survival: 5, altruism: 5 },
|
||||
perkSlots: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const MISSION = MissionSchema.parse({
|
||||
id: 'b2c3d4e5-f6a7-4890-b123-c4d5e6f7a801',
|
||||
groupId: null,
|
||||
participants: [{ survivorId: SURVIVOR.id, state: 'active', hookCount: 0 }],
|
||||
difficulty: 2,
|
||||
status: 'active',
|
||||
encounterLibraryVersion: '1.0.0',
|
||||
nextTickAt: new Date(Date.now() + 60000).toISOString(),
|
||||
tickIndex: 3,
|
||||
startedAt: new Date().toISOString(),
|
||||
endedAt: null,
|
||||
});
|
||||
|
||||
const MISSION_STATE = { mission: MISSION, survivors: [SURVIVOR], recentLog: [] };
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [PanelShellComponent],
|
||||
template: `<app-panel-shell />`,
|
||||
})
|
||||
class HostComponent {}
|
||||
|
||||
describe('PanelShellComponent', () => {
|
||||
let controller: HttpTestingController;
|
||||
let authService: TwitchAuthService;
|
||||
let visibilityCallback: ((v: boolean, ctx: TwitchContext) => void) | undefined;
|
||||
|
||||
function mountTwitchExt(jwt = makeJwt()) {
|
||||
(window as Window & { Twitch?: unknown }).Twitch = {
|
||||
ext: {
|
||||
onAuthorized: (cb: (a: TwitchAuth) => void) =>
|
||||
cb({ channelId: 'ch1', clientId: 'c1', token: jwt, userId: 'U123' }),
|
||||
onContext: () => undefined,
|
||||
onVisibilityChanged: (cb: (v: boolean, ctx: TwitchContext) => void) => {
|
||||
visibilityCallback = cb;
|
||||
},
|
||||
listen: () => undefined,
|
||||
unlisten: () => undefined,
|
||||
send: () => undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createComponent() {
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
visibilityCallback = undefined;
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: EBS_BASE_URL, useValue: BASE },
|
||||
],
|
||||
});
|
||||
TestBed.overrideComponent(PanelShellComponent, {
|
||||
set: {
|
||||
imports: [MinimisedPanelStub, AmbientEventStub, ExpandedPanelStub],
|
||||
},
|
||||
});
|
||||
controller = TestBed.inject(HttpTestingController);
|
||||
authService = TestBed.inject(TwitchAuthService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
controller.verify();
|
||||
delete (window as Window & { Twitch?: unknown }).Twitch;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders nothing before onAuthorized fires', () => {
|
||||
(window as Window & { Twitch?: unknown }).Twitch = {
|
||||
ext: {
|
||||
onAuthorized: () => undefined,
|
||||
onContext: () => undefined,
|
||||
onVisibilityChanged: (cb: (v: boolean, ctx: TwitchContext) => void) => { visibilityCallback = cb; },
|
||||
listen: () => undefined,
|
||||
unlisten: () => undefined,
|
||||
send: () => undefined,
|
||||
},
|
||||
};
|
||||
const fixture = createComponent();
|
||||
// Angular @if leaves comment nodes; check no element children are rendered
|
||||
const shell = fixture.nativeElement.querySelector('app-panel-shell') as HTMLElement;
|
||||
expect(shell.querySelectorAll('*').length).toBe(0);
|
||||
});
|
||||
|
||||
it('shows onboarding when authorized but no active mission', () => {
|
||||
mountTwitchExt();
|
||||
const fixture = createComponent();
|
||||
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('The fog stirs');
|
||||
});
|
||||
|
||||
it('shows anonymous panel for A-prefixed opaque_user_id', () => {
|
||||
mountTwitchExt(makeAnonymousJwt());
|
||||
const fixture = createComponent();
|
||||
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.anon-panel')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows minimised panel when mission state is available', () => {
|
||||
mountTwitchExt();
|
||||
const fixture = createComponent();
|
||||
|
||||
controller.expectOne(STATE_URL).flush(MISSION_STATE);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('toggles to expanded on lantern click and back on close', () => {
|
||||
mountTwitchExt();
|
||||
const fixture = createComponent();
|
||||
controller.expectOne(STATE_URL).flush(MISSION_STATE);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Emit lanternClick from the stub component
|
||||
fixture.debugElement.query(By.directive(MinimisedPanelStub))
|
||||
.componentInstance.lanternClick.emit();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('app-expanded-panel')).not.toBeNull();
|
||||
|
||||
// Emit close from the expanded stub
|
||||
fixture.debugElement.query(By.directive(ExpandedPanelStub))
|
||||
.componentInstance.close.emit();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows ambient event on injury and auto-dismisses after 4s', async () => {
|
||||
vi.useFakeTimers();
|
||||
mountTwitchExt();
|
||||
const fixture = createComponent();
|
||||
controller.expectOne(STATE_URL).flush(MISSION_STATE);
|
||||
fixture.detectChanges();
|
||||
|
||||
const injuredState = {
|
||||
...MISSION_STATE,
|
||||
survivors: [{ ...SURVIVOR, state: 'injured' as const }],
|
||||
};
|
||||
controller.expectNone(STATE_URL);
|
||||
|
||||
// Simulate a PubSub tick arriving that updates state to injured
|
||||
controller.expectNone(STATE_URL);
|
||||
// Directly set state by triggering a second fetch with an injured survivor
|
||||
// (In production this comes from PubSub → store.refresh() → REST)
|
||||
// We trigger via store.refresh() here
|
||||
TestBed.inject(
|
||||
(await import('../mission/mission-state.store')).MissionStateStore
|
||||
).refresh();
|
||||
controller.expectOne(STATE_URL).flush(injuredState);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('app-ambient-event')).not.toBeNull();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('drops ambient event when overlay is hidden', async () => {
|
||||
mountTwitchExt();
|
||||
const fixture = createComponent();
|
||||
controller.expectOne(STATE_URL).flush(MISSION_STATE);
|
||||
fixture.detectChanges();
|
||||
|
||||
visibilityCallback!(false, {} as TwitchContext);
|
||||
|
||||
const injuredState = {
|
||||
...MISSION_STATE,
|
||||
survivors: [{ ...SURVIVOR, state: 'injured' as const }],
|
||||
};
|
||||
TestBed.inject(
|
||||
(await import('../mission/mission-state.store')).MissionStateStore
|
||||
).refresh();
|
||||
controller.expectOne(STATE_URL).flush(injuredState);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Still minimised — ambient event dropped while hidden
|
||||
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
177
apps/overlay/src/app/panel/panel-shell.component.ts
Normal file
177
apps/overlay/src/app/panel/panel-shell.component.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
||||
import { distinctUntilChanged, filter, skip } from 'rxjs';
|
||||
import { EbsApiService } from '../ebs/ebs-api.service';
|
||||
import { MissionStateStore } from '../mission/mission-state.store';
|
||||
import { TwitchAuthService } from '../twitch/twitch-auth.service';
|
||||
import { AmbientEventComponent, AmbientEventData } from './ambient-event.component';
|
||||
import { ExpandedPanelComponent } from './expanded-panel.component';
|
||||
import { MinimisedPanelComponent } from './minimised-panel.component';
|
||||
|
||||
type OverlayView = 'minimised' | 'ambient' | 'expanded';
|
||||
|
||||
function detectAmbientEvent(
|
||||
prev: NonNullable<MissionStateResponse>,
|
||||
curr: NonNullable<MissionStateResponse>
|
||||
): AmbientEventData | null {
|
||||
if (curr.mission.status === 'success' && prev.mission.status !== 'success') {
|
||||
return { type: 'mission-complete', description: 'Mission complete.' };
|
||||
}
|
||||
if (curr.mission.status === 'sacrifice' && prev.mission.status !== 'sacrifice') {
|
||||
return { type: 'sacrifice', description: 'Sacrificed to the fog.' };
|
||||
}
|
||||
for (const survivor of curr.survivors) {
|
||||
const prevSurvivor = prev.survivors.find((s) => s.id === survivor.id);
|
||||
if (prevSurvivor && survivor.state !== prevSurvivor.state) {
|
||||
if (survivor.state === 'injured' || survivor.state === 'downed') {
|
||||
return { type: 'injury', description: `${survivor.name} is injured.` };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-panel-shell',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MinimisedPanelComponent, AmbientEventComponent, ExpandedPanelComponent],
|
||||
template: `
|
||||
@if (authService.auth()) {
|
||||
@if (!authService.isLoggedIn()) {
|
||||
<div class="anon-panel">
|
||||
<div class="lantern-desaturated"></div>
|
||||
</div>
|
||||
} @else if (!store.state()) {
|
||||
<div class="onboarding-panel">
|
||||
<p class="onboarding-text">The fog stirs. Awaiting a survivor.</p>
|
||||
</div>
|
||||
} @else {
|
||||
@switch (view()) {
|
||||
@case ('minimised') {
|
||||
<app-minimised-panel
|
||||
[missionState]="store.state()!"
|
||||
(lanternClick)="onLanternClick()"
|
||||
/>
|
||||
}
|
||||
@case ('ambient') {
|
||||
<app-ambient-event
|
||||
[event]="pendingEvent()!"
|
||||
(dismissed)="onAmbientDismiss()"
|
||||
/>
|
||||
}
|
||||
@case ('expanded') {
|
||||
<app-expanded-panel
|
||||
[missionState]="store.state()!"
|
||||
(close)="onLanternClick()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
styles: [`
|
||||
:host { display: block; position: fixed; bottom: 16px; left: 16px; z-index: 9999; }
|
||||
.anon-panel, .onboarding-panel {
|
||||
width: 290px;
|
||||
min-height: 56px;
|
||||
background: rgba(15, 18, 22, 0.88);
|
||||
padding: 12px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.lantern-desaturated {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #2a2e34;
|
||||
border: 3px solid #4a4e54;
|
||||
}
|
||||
.onboarding-text {
|
||||
font-family: 'JetBrains Mono', 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #6a7080;
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class PanelShellComponent implements OnInit {
|
||||
protected readonly authService = inject(TwitchAuthService);
|
||||
protected readonly store = inject(MissionStateStore);
|
||||
// EbsApiService eagerly resolved to ensure EBS_BASE_URL token is
|
||||
// available before any child component triggers a request.
|
||||
private readonly _ebs = inject(EbsApiService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly view = signal<OverlayView>('minimised');
|
||||
protected readonly pendingEvent = signal<AmbientEventData | null>(null);
|
||||
|
||||
private prevState: MissionStateResponse = null;
|
||||
private ambientTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
// Visibility restore → reconcile missed ticks.
|
||||
// skip(1) prevents a double-fetch on startup (initial isVisible value is true).
|
||||
toObservable(this.authService.isVisible).pipe(
|
||||
distinctUntilChanged(),
|
||||
skip(1),
|
||||
filter(Boolean),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
).subscribe(() => this.store.refresh());
|
||||
|
||||
// Detect significant state changes and trigger ambient events.
|
||||
effect(() => {
|
||||
const current = this.store.state();
|
||||
untracked(() => this.handleStateChange(current));
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.authService.init();
|
||||
this.store.init();
|
||||
}
|
||||
|
||||
protected onLanternClick(): void {
|
||||
this.view.update((v) => (v === 'expanded' ? 'minimised' : 'expanded'));
|
||||
}
|
||||
|
||||
protected onAmbientDismiss(): void {
|
||||
this.clearAmbientTimer();
|
||||
this.view.set('minimised');
|
||||
this.pendingEvent.set(null);
|
||||
}
|
||||
|
||||
private handleStateChange(current: MissionStateResponse): void {
|
||||
const prev = this.prevState;
|
||||
this.prevState = current;
|
||||
|
||||
if (!prev || !current) return;
|
||||
if (this.view() === 'expanded') return; // Don't interrupt expanded view
|
||||
if (!this.authService.isVisible()) return; // Drop events while hidden
|
||||
|
||||
const event = detectAmbientEvent(prev, current);
|
||||
if (!event) return;
|
||||
|
||||
this.clearAmbientTimer();
|
||||
this.pendingEvent.set(event);
|
||||
this.view.set('ambient');
|
||||
this.ambientTimer = setTimeout(() => this.onAmbientDismiss(), 4000);
|
||||
}
|
||||
|
||||
private clearAmbientTimer(): void {
|
||||
if (this.ambientTimer !== null) {
|
||||
clearTimeout(this.ambientTimer);
|
||||
this.ambientTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
107
apps/overlay/src/app/twitch/twitch-auth.service.spec.ts
Normal file
107
apps/overlay/src/app/twitch/twitch-auth.service.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { TwitchAuthService, TwitchJwtPayload } from './twitch-auth.service';
|
||||
|
||||
function makeJwt(payload: Partial<TwitchJwtPayload> = {}): string {
|
||||
const full: TwitchJwtPayload = {
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
opaque_user_id: 'U12345678',
|
||||
user_id: '12345678',
|
||||
channel_id: '987654321',
|
||||
role: 'viewer',
|
||||
...payload,
|
||||
};
|
||||
const encoded = btoa(JSON.stringify(full))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
return `eyJhbGciOiJIUzI1NiJ9.${encoded}.sig`;
|
||||
}
|
||||
|
||||
function makeTwitchAuth(payload: Partial<TwitchJwtPayload> = {}): TwitchAuth {
|
||||
return {
|
||||
channelId: payload.channel_id ?? '987654321',
|
||||
clientId: 'test_client',
|
||||
token: makeJwt(payload),
|
||||
userId: payload.opaque_user_id ?? 'U12345678',
|
||||
};
|
||||
}
|
||||
|
||||
describe('TwitchAuthService', () => {
|
||||
let service: TwitchAuthService;
|
||||
let extCallbacks: {
|
||||
onAuthorized?: (auth: TwitchAuth) => void;
|
||||
onContext?: (ctx: TwitchContext, changed: (keyof TwitchContext)[]) => void;
|
||||
onVisibilityChanged?: (visible: boolean, ctx: TwitchContext) => void;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
extCallbacks = {};
|
||||
(window as Window & { Twitch?: unknown }).Twitch = {
|
||||
ext: {
|
||||
onAuthorized: (cb: (auth: TwitchAuth) => void) => { extCallbacks.onAuthorized = cb; },
|
||||
onContext: (cb: (ctx: TwitchContext, changed: (keyof TwitchContext)[]) => void) => { extCallbacks.onContext = cb; },
|
||||
onVisibilityChanged: (cb: (visible: boolean, ctx: TwitchContext) => void) => { extCallbacks.onVisibilityChanged = cb; },
|
||||
listen: () => undefined,
|
||||
unlisten: () => undefined,
|
||||
send: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
service = new TwitchAuthService();
|
||||
service.init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as Window & { Twitch?: unknown }).Twitch;
|
||||
});
|
||||
|
||||
it('exposes null auth before onAuthorized fires', () => {
|
||||
expect(service.auth()).toBeNull();
|
||||
expect(service.jwtPayload()).toBeNull();
|
||||
expect(service.isLoggedIn()).toBe(false);
|
||||
expect(service.channelId()).toBeNull();
|
||||
});
|
||||
|
||||
it('sets auth and decoded payload when onAuthorized fires', () => {
|
||||
extCallbacks.onAuthorized!(makeTwitchAuth());
|
||||
|
||||
expect(service.auth()).not.toBeNull();
|
||||
expect(service.jwtPayload()?.opaque_user_id).toBe('U12345678');
|
||||
expect(service.jwtPayload()?.channel_id).toBe('987654321');
|
||||
expect(service.channelId()).toBe('987654321');
|
||||
});
|
||||
|
||||
it('reports isLoggedIn true for U-prefixed opaque_user_id', () => {
|
||||
extCallbacks.onAuthorized!(makeTwitchAuth({ opaque_user_id: 'U99999999' }));
|
||||
expect(service.isLoggedIn()).toBe(true);
|
||||
});
|
||||
|
||||
it('reports isLoggedIn false for A-prefixed opaque_user_id', () => {
|
||||
extCallbacks.onAuthorized!(makeTwitchAuth({ opaque_user_id: 'Aanonymous' }));
|
||||
expect(service.isLoggedIn()).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults isVisible to true', () => {
|
||||
expect(service.isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('updates isVisible when onVisibilityChanged fires', () => {
|
||||
const ctx = {} as TwitchContext;
|
||||
extCallbacks.onVisibilityChanged!(false, ctx);
|
||||
expect(service.isVisible()).toBe(false);
|
||||
|
||||
extCallbacks.onVisibilityChanged!(true, ctx);
|
||||
expect(service.isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('updates context when onContext fires', () => {
|
||||
const ctx = { game: 'test-game' } as TwitchContext;
|
||||
extCallbacks.onContext!(ctx, ['game']);
|
||||
expect(service.context()?.game).toBe('test-game');
|
||||
});
|
||||
|
||||
it('updates context from onVisibilityChanged', () => {
|
||||
const ctx = { isFullScreen: true } as TwitchContext;
|
||||
extCallbacks.onVisibilityChanged!(false, ctx);
|
||||
expect(service.context()?.isFullScreen).toBe(true);
|
||||
});
|
||||
});
|
||||
87
apps/overlay/src/app/twitch/twitch-auth.service.ts
Normal file
87
apps/overlay/src/app/twitch/twitch-auth.service.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { computed, Injectable, isDevMode, signal } from '@angular/core';
|
||||
|
||||
export interface TwitchJwtPayload {
|
||||
exp: number;
|
||||
opaque_user_id: string;
|
||||
user_id?: string;
|
||||
channel_id: string;
|
||||
role: 'viewer' | 'broadcaster' | 'external';
|
||||
is_unlinked?: boolean;
|
||||
pubsub_perms?: { listen?: string[]; send?: string[] };
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): TwitchJwtPayload | null {
|
||||
try {
|
||||
const part = token.split('.')[1];
|
||||
return JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/')));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDevAuth(): TwitchAuth {
|
||||
const payload: TwitchJwtPayload = {
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
opaque_user_id: 'UDEV000001',
|
||||
user_id: 'dev_user_1',
|
||||
channel_id: 'dev_channel_1',
|
||||
role: 'viewer',
|
||||
};
|
||||
const encoded = btoa(JSON.stringify(payload))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
return {
|
||||
channelId: payload.channel_id,
|
||||
clientId: 'dev_client_id',
|
||||
token: `eyJhbGciOiJIUzI1NiJ9.${encoded}.dev`,
|
||||
userId: payload.opaque_user_id,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TwitchAuthService {
|
||||
private readonly _auth = signal<TwitchAuth | null>(null);
|
||||
private readonly _context = signal<TwitchContext | null>(null);
|
||||
private readonly _isVisible = signal(true);
|
||||
|
||||
readonly auth = this._auth.asReadonly();
|
||||
readonly context = this._context.asReadonly();
|
||||
readonly isVisible = this._isVisible.asReadonly();
|
||||
|
||||
readonly jwtPayload = computed(() => {
|
||||
const auth = this._auth();
|
||||
return auth ? decodeJwtPayload(auth.token) : null;
|
||||
});
|
||||
|
||||
readonly isLoggedIn = computed(
|
||||
() => this.jwtPayload()?.opaque_user_id.startsWith('U') ?? false,
|
||||
);
|
||||
|
||||
readonly channelId = computed(() => this.jwtPayload()?.channel_id ?? null);
|
||||
|
||||
init(): void {
|
||||
if (isDevMode() && !window.Twitch?.ext) {
|
||||
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
||||
console.warn('[TwitchAuth] Twitch SDK not found — using dev auth');
|
||||
}
|
||||
this._auth.set(buildDevAuth());
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.Twitch) {
|
||||
window.Twitch.ext.onAuthorized((auth) => {
|
||||
this._auth.set(auth);
|
||||
});
|
||||
|
||||
window.Twitch.ext.onContext((context) => {
|
||||
this._context.set(context);
|
||||
});
|
||||
|
||||
window.Twitch.ext.onVisibilityChanged((isVisible, context) => {
|
||||
this._isVisible.set(isVisible);
|
||||
this._context.set(context);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
11
apps/overlay/src/test-setup.ts
Normal file
11
apps/overlay/src/test-setup.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import '@angular/compiler';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
import {
|
||||
BrowserTestingModule,
|
||||
platformBrowserTesting,
|
||||
} from '@angular/platform-browser/testing';
|
||||
|
||||
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting(), {
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
47
apps/overlay/src/twitch-ext.d.ts
vendored
Normal file
47
apps/overlay/src/twitch-ext.d.ts
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
interface TwitchAuth {
|
||||
channelId: string;
|
||||
clientId: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
helixToken?: string;
|
||||
}
|
||||
|
||||
interface TwitchContext {
|
||||
game: string;
|
||||
language: string;
|
||||
mode: 'viewer' | 'dashboard' | 'config';
|
||||
isFullScreen: boolean;
|
||||
isPaused: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
arePlayerControlsVisible: boolean;
|
||||
bitrate: number;
|
||||
bufferSize: number;
|
||||
displayResolution: string;
|
||||
videoCurrentTime: number;
|
||||
videoDuration: number;
|
||||
videoResolution: string;
|
||||
hlsLatencyBroadcaster: number;
|
||||
}
|
||||
|
||||
interface TwitchExt {
|
||||
onAuthorized(callback: (auth: TwitchAuth) => void): void;
|
||||
onContext(
|
||||
callback: (context: TwitchContext, changed: (keyof TwitchContext)[]) => void
|
||||
): void;
|
||||
onVisibilityChanged(
|
||||
callback: (isVisible: boolean, context: TwitchContext) => void
|
||||
): void;
|
||||
listen(
|
||||
target: string,
|
||||
callback: (target: string, contentType: string, message: string) => void
|
||||
): void;
|
||||
unlisten(
|
||||
target: string,
|
||||
callback: (target: string, contentType: string, message: string) => void
|
||||
): void;
|
||||
send(target: string, contentType: string, message: object): void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
Twitch?: { ext: TwitchExt };
|
||||
}
|
||||
21
apps/overlay/vitest.config.mts
Normal file
21
apps/overlay/vitest.config.mts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/apps/overlay',
|
||||
plugins: [nxViteTsPaths()],
|
||||
test: {
|
||||
name: 'overlay',
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'happy-dom',
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: '../../coverage/apps/overlay',
|
||||
provider: 'v8' as const,
|
||||
},
|
||||
},
|
||||
}));
|
||||
44
docs/adr/0008-angular-for-overlay-frontend.md
Normal file
44
docs/adr/0008-angular-for-overlay-frontend.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 0008 — Angular for the overlay frontend
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-05-07
|
||||
|
||||
## Context and problem statement
|
||||
|
||||
`PROJECT_CONTEXT.md` explicitly deferred the overlay framework choice to Stage 2, noting that Vanilla TypeScript + Lit "is likely a better fit" for bundle size and Twitch review simplicity. The overlay app was scaffolded with Angular as a placeholder. A decision is required before building any real overlay components.
|
||||
|
||||
## Decision drivers
|
||||
|
||||
- **Bundle size.** Twitch reviews extensions manually; a smaller bundle reduces scrutiny risk and load time over arbitrary stream content.
|
||||
- **Developer velocity.** Familiarity with a framework dominates iteration speed, especially for a solo build.
|
||||
- **Component model fit.** The overlay has three display states and a PubSub subscription — straightforward enough for any framework.
|
||||
- **Existing scaffold.** The Angular app already exists in the workspace; switching adds setup cost with no immediate payoff.
|
||||
|
||||
## Considered options
|
||||
|
||||
1. **Angular.** Full framework; already scaffolded; developer is expert-level.
|
||||
2. **Lit.** Web-component library; ~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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
8
libs/api-interfaces/src/lib/encounter-definition.ts
Normal file
8
libs/api-interfaces/src/lib/encounter-definition.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const EncounterDefinitionSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
baseProbability: z.number().min(0).max(1),
|
||||
tags: z.array(z.string()),
|
||||
});
|
||||
export type EncounterDefinition = z.infer<typeof EncounterDefinitionSchema>;
|
||||
23
libs/api-interfaces/src/lib/mission-state.ts
Normal file
23
libs/api-interfaces/src/lib/mission-state.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
import { EncounterResultSchema } from './encounter';
|
||||
import { MissionSchema } from './mission';
|
||||
import { SurvivorSchema } from './survivor';
|
||||
|
||||
export const MissionStateResponseSchema = z
|
||||
.object({
|
||||
mission: MissionSchema,
|
||||
survivors: z.array(SurvivorSchema),
|
||||
recentLog: z.array(EncounterResultSchema).max(20),
|
||||
})
|
||||
.nullable();
|
||||
export type MissionStateResponse = z.infer<typeof MissionStateResponseSchema>;
|
||||
|
||||
export const StartMissionRequestSchema = z.object({
|
||||
difficulty: z.number().int().min(1).max(3),
|
||||
});
|
||||
export type StartMissionRequest = z.infer<typeof StartMissionRequestSchema>;
|
||||
|
||||
export const ChoosePerkRequestSchema = z.object({
|
||||
perkKey: z.string().min(1),
|
||||
});
|
||||
export type ChoosePerkRequest = z.infer<typeof ChoosePerkRequestSchema>;
|
||||
@@ -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}',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
165
libs/encounter-library/src/lib/encounters.json
Normal file
165
libs/encounter-library/src/lib/encounters.json
Normal file
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"encounters": [
|
||||
{
|
||||
"key": "generator_repair",
|
||||
"baseProbability": 0.55,
|
||||
"tags": ["generator", "objectives"],
|
||||
"tier": 1,
|
||||
"flavorSuccess": [
|
||||
"The generator sputters to life. Light floods the area.",
|
||||
"Sparks fly, then hum — the generator catches.",
|
||||
"Wires connected. The machine breathes again."
|
||||
],
|
||||
"flavorFailure": [
|
||||
"The generator kicks back. Too many watchers in the dark.",
|
||||
"The mechanism jams. Footsteps echo nearby.",
|
||||
"A noise betrays the position. The generator goes cold."
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "totem_cleanse",
|
||||
"baseProbability": 0.50,
|
||||
"tags": ["totem", "altruistic", "objectives"],
|
||||
"tier": 1,
|
||||
"flavorSuccess": [
|
||||
"The totem crumbles. Its curse lifts from the fog.",
|
||||
"Bones scatter. The hex dissolves into smoke.",
|
||||
"The ritual unravels. Something distant screams."
|
||||
],
|
||||
"flavorFailure": [
|
||||
"The totem resists. Its pull is stronger than expected.",
|
||||
"A presence drives the survivor back before the cleanse completes.",
|
||||
"The hex holds. Dread thickens the air."
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "chest_search",
|
||||
"baseProbability": 0.45,
|
||||
"tags": ["chest", "item"],
|
||||
"tier": 1,
|
||||
"flavorSuccess": [
|
||||
"The chest yields a worn medkit. Small mercies.",
|
||||
"A flashlight, still charged. The fog recedes slightly.",
|
||||
"A useful tool among the debris."
|
||||
],
|
||||
"flavorFailure": [
|
||||
"The chest is empty. Only rust and regret.",
|
||||
"The lid splinters — nothing useful inside.",
|
||||
"A noise nearby cuts the search short."
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "hook_escape",
|
||||
"baseProbability": 0.40,
|
||||
"tags": ["hook", "survival"],
|
||||
"tier": 2,
|
||||
"flavorSuccess": [
|
||||
"With grim determination, the survivor slips free.",
|
||||
"Arms aching, the hook releases. Freedom, for now.",
|
||||
"A desperate push — the chains give way."
|
||||
],
|
||||
"flavorFailure": [
|
||||
"The struggle exhausts. The hook holds.",
|
||||
"Every movement drives the barb deeper. Stay still.",
|
||||
"The fog presses in. The hook remains."
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "exit_gate",
|
||||
"baseProbability": 0.50,
|
||||
"tags": ["exit", "objectives"],
|
||||
"tier": 2,
|
||||
"flavorSuccess": [
|
||||
"The gate grinds open. Cold air rushes in.",
|
||||
"Generators humming, the lock gives. Almost there.",
|
||||
"The exit yields. Light from outside cuts the fog."
|
||||
],
|
||||
"flavorFailure": [
|
||||
"The gate mechanism is jammed. Precious seconds lost.",
|
||||
"A shadow falls across the panel. The attempt abandoned.",
|
||||
"The switch is stuck. The gate stays shut."
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "patrol_avoid",
|
||||
"baseProbability": 0.60,
|
||||
"tags": ["stealth", "survival"],
|
||||
"tier": 1,
|
||||
"flavorSuccess": [
|
||||
"Still as stone. The threat passes without noticing.",
|
||||
"A breath held long — then silence. Safe.",
|
||||
"The fog swallows the survivor whole. Unseen."
|
||||
],
|
||||
"flavorFailure": [
|
||||
"A twig snaps. Eye contact — then the chase begins.",
|
||||
"The survivor misjudges the angle. Spotted.",
|
||||
"Heartbeat too loud. Presence too close."
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "medkit_use",
|
||||
"baseProbability": 0.65,
|
||||
"tags": ["healing", "altruistic", "survival"],
|
||||
"tier": 1,
|
||||
"flavorSuccess": [
|
||||
"Bandages tight, the wound closes. Pain recedes.",
|
||||
"The medkit does its job. The survivor steadies.",
|
||||
"A few tense minutes — injuries tended."
|
||||
],
|
||||
"flavorFailure": [
|
||||
"Supplies exhausted before the job is done.",
|
||||
"Shaking hands fumble the medkit. Time runs out.",
|
||||
"The wound is worse than it looked. Supplies fall short."
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "pallet_drop",
|
||||
"baseProbability": 0.55,
|
||||
"tags": ["survival", "escape"],
|
||||
"tier": 2,
|
||||
"flavorSuccess": [
|
||||
"The pallet crashes down. A moment bought.",
|
||||
"Timber splinters between them. Distance gained.",
|
||||
"The drop lands true. The chase falters."
|
||||
],
|
||||
"flavorFailure": [
|
||||
"The pallet drops wide. No gap created.",
|
||||
"Too slow — the obstacle proves useless.",
|
||||
"The throw miscalculated. The pursuit continues."
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "basement_search",
|
||||
"baseProbability": 0.35,
|
||||
"tags": ["chest", "item", "high-risk"],
|
||||
"tier": 3,
|
||||
"flavorSuccess": [
|
||||
"The basement yields rare supplies. Worth the risk.",
|
||||
"A pristine toolbox — almost worth dying for.",
|
||||
"The gamble paid off. The survivor emerges with something valuable."
|
||||
],
|
||||
"flavorFailure": [
|
||||
"The basement was a trap. Retreat costs dearly.",
|
||||
"The stairwell offers no escape. A mistake made clear.",
|
||||
"The risk was not worth the reward found — nothing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "hatch_find",
|
||||
"baseProbability": 0.30,
|
||||
"tags": ["exit", "survival", "high-risk"],
|
||||
"tier": 3,
|
||||
"flavorSuccess": [
|
||||
"The hatch sighs open. One last mercy from the fog.",
|
||||
"A sound — familiar, haunting. The hatch, just ahead.",
|
||||
"Against all odds, the escape route reveals itself."
|
||||
],
|
||||
"flavorFailure": [
|
||||
"The hatch is nowhere. Only fog and silence.",
|
||||
"Close — so close. Then it closes.",
|
||||
"The sound was something else entirely."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
"types": ["node"],
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"resolveJsonModule": true,
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
|
||||
@@ -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}',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from './lib/mission-logic';
|
||||
export * from './lib/encounter-resolver';
|
||||
|
||||
399
libs/mission-logic/src/lib/encounter-resolver.spec.ts
Normal file
399
libs/mission-logic/src/lib/encounter-resolver.spec.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import * as fc from 'fast-check';
|
||||
import { resolveEncounter, type ResolverInput } from './encounter-resolver';
|
||||
import type { EncounterDefinition, Perk } from '@fog-explorer/api-interfaces';
|
||||
|
||||
// ── Arbitraries ──────────────────────────────────────────────────────────────
|
||||
|
||||
const arbSeed = fc.string({ minLength: 8, maxLength: 16 });
|
||||
|
||||
const arbEncounter = (tags: string[] = []): fc.Arbitrary<EncounterDefinition> =>
|
||||
fc.record({
|
||||
key: fc.constantFrom('generator', 'totem', 'chest', 'hook', 'exit-gate'),
|
||||
baseProbability: fc.float({ min: 0, max: 1, noNaN: true }),
|
||||
tags: fc.constant(tags),
|
||||
});
|
||||
|
||||
const arbStats = fc.record({
|
||||
objectives: fc.integer({ min: 1, max: 10 }),
|
||||
survival: fc.integer({ min: 1, max: 10 }),
|
||||
altruism: fc.integer({ min: 1, max: 10 }),
|
||||
});
|
||||
|
||||
const arbInput = (overrides: Partial<ResolverInput> = {}): fc.Arbitrary<ResolverInput> =>
|
||||
fc.record({
|
||||
seed: arbSeed,
|
||||
missionId: fc.uuid(),
|
||||
tickIndex: fc.nat({ max: 999 }),
|
||||
difficulty: fc.integer({ min: 1, max: 3 }),
|
||||
encounter: arbEncounter(),
|
||||
survivor: fc.record({
|
||||
id: fc.uuid(),
|
||||
state: fc.constantFrom('active' as const, 'injured' as const),
|
||||
stats: arbStats,
|
||||
perkSlots: fc.constant<Perk[]>([]),
|
||||
hookCount: fc.integer({ min: 0, max: 2 }),
|
||||
}),
|
||||
}).map((base) => ({ ...base, ...overrides }));
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeInput(partial: Partial<ResolverInput> = {}): ResolverInput {
|
||||
return {
|
||||
seed: 'fixed-seed',
|
||||
missionId: '00000000-0000-4000-a000-000000000001',
|
||||
tickIndex: 0,
|
||||
difficulty: 1,
|
||||
encounter: { key: 'generator', baseProbability: 0.5, tags: [] },
|
||||
survivor: {
|
||||
id: '00000000-0000-4000-a000-000000000002',
|
||||
state: 'active',
|
||||
stats: { objectives: 5, survival: 5, altruism: 5 },
|
||||
perkSlots: [],
|
||||
hookCount: 0,
|
||||
},
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Unit tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('resolveEncounter', () => {
|
||||
it('is deterministic: same seed always produces same result', () => {
|
||||
const input = makeInput();
|
||||
const a = resolveEncounter(input);
|
||||
const b = resolveEncounter(input);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it('produces different results for different seeds', () => {
|
||||
const results = new Set(
|
||||
Array.from({ length: 20 }, (_, i) =>
|
||||
resolveEncounter(makeInput({ seed: `seed-${i}` })).success
|
||||
)
|
||||
);
|
||||
// With 20 different seeds at p≈0.6 both true and false should appear
|
||||
expect(results.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('state change is null on success', () => {
|
||||
// Force a near-certain success with baseProbability=1
|
||||
const result = resolveEncounter(makeInput({ encounter: { key: 'generator', baseProbability: 1, tags: [] } }));
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.survivorStateChange).toBeNull();
|
||||
});
|
||||
|
||||
it('injured survivor transitions to downed on failure', () => {
|
||||
// Force failure: baseProbability=0
|
||||
const result = resolveEncounter(
|
||||
makeInput({
|
||||
encounter: { key: 'hook', baseProbability: 0, tags: [] },
|
||||
survivor: {
|
||||
id: '00000000-0000-4000-a000-000000000002',
|
||||
state: 'injured',
|
||||
stats: { objectives: 1, survival: 5, altruism: 1 },
|
||||
perkSlots: [],
|
||||
hookCount: 1,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.survivorStateChange).toEqual({ from: 'injured', to: 'downed' });
|
||||
});
|
||||
|
||||
it('downed survivor at hookCount 2 transitions to sacrificed on failure', () => {
|
||||
const result = resolveEncounter(
|
||||
makeInput({
|
||||
encounter: { key: 'hook', baseProbability: 0, tags: [] },
|
||||
survivor: {
|
||||
id: '00000000-0000-4000-a000-000000000002',
|
||||
state: 'downed',
|
||||
stats: { objectives: 1, survival: 1, altruism: 1 },
|
||||
perkSlots: [],
|
||||
hookCount: 2,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.survivorStateChange).toEqual({ from: 'downed', to: 'sacrificed' });
|
||||
});
|
||||
|
||||
it('downed survivor below hookCount 2 has no state change on failure', () => {
|
||||
const result = resolveEncounter(
|
||||
makeInput({
|
||||
encounter: { key: 'hook', baseProbability: 0, tags: [] },
|
||||
survivor: {
|
||||
id: '00000000-0000-4000-a000-000000000002',
|
||||
state: 'downed',
|
||||
stats: { objectives: 1, survival: 5, altruism: 1 },
|
||||
perkSlots: [],
|
||||
hookCount: 1,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.survivorStateChange).toBeNull();
|
||||
});
|
||||
|
||||
it('perk modifier with matching tag applies to success chance', () => {
|
||||
const perk: Perk = {
|
||||
id: '00000000-0000-4000-a000-000000000003',
|
||||
key: 'adrenaline',
|
||||
name: 'Adrenaline',
|
||||
description: '',
|
||||
tags: [],
|
||||
modifiers: [
|
||||
{ target: 'successChance', type: 'additive', amount: 0.5, condition: { encounterTags: ['generator'] } },
|
||||
],
|
||||
};
|
||||
// With p=0 base but +0.5 from perk on matching tag → p=0.5 → some successes
|
||||
const results = Array.from({ length: 10 }, (_, i) =>
|
||||
resolveEncounter(
|
||||
makeInput({
|
||||
seed: `perk-test-${i}`,
|
||||
encounter: { key: 'gen', baseProbability: 0, tags: ['generator'] },
|
||||
survivor: {
|
||||
id: '00000000-0000-4000-a000-000000000002',
|
||||
state: 'active',
|
||||
stats: { objectives: 1, survival: 5, altruism: 1 },
|
||||
perkSlots: [perk],
|
||||
hookCount: 0,
|
||||
},
|
||||
})
|
||||
).success
|
||||
);
|
||||
expect(results.some(Boolean)).toBe(true);
|
||||
});
|
||||
|
||||
it('perk modifier without matching tag does not apply', () => {
|
||||
const perk: Perk = {
|
||||
id: '00000000-0000-4000-a000-000000000003',
|
||||
key: 'irrelevant-perk',
|
||||
name: 'Irrelevant',
|
||||
description: '',
|
||||
tags: [],
|
||||
modifiers: [
|
||||
{ target: 'successChance', type: 'additive', amount: 0.99, condition: { encounterTags: ['totem'] } },
|
||||
],
|
||||
};
|
||||
// p=0, perk requires 'totem' but encounter has 'generator' → still fails
|
||||
const result = resolveEncounter(
|
||||
makeInput({
|
||||
encounter: { key: 'gen', baseProbability: 0, tags: ['generator'] },
|
||||
survivor: {
|
||||
id: '00000000-0000-4000-a000-000000000002',
|
||||
state: 'active',
|
||||
stats: { objectives: 1, survival: 5, altruism: 1 },
|
||||
perkSlots: [perk],
|
||||
hookCount: 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.modifiersApplied).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('logText is non-empty for all outcomes', () => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const result = resolveEncounter(makeInput({ seed: `log-test-${i}` }));
|
||||
expect(result.logText.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Property-based tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('resolveEncounter — property-based', () => {
|
||||
it('result fields match input identifiers', () => {
|
||||
fc.assert(
|
||||
fc.property(arbInput(), (input) => {
|
||||
const result = resolveEncounter(input);
|
||||
expect(result.missionId).toBe(input.missionId);
|
||||
expect(result.survivorId).toBe(input.survivor.id);
|
||||
expect(result.encounterKey).toBe(input.encounter.key);
|
||||
expect(result.tickIndex).toBe(input.tickIndex);
|
||||
expect(result.seed).toBe(input.seed);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('state change is always null when encounter succeeds', () => {
|
||||
fc.assert(
|
||||
fc.property(arbInput(), (input) => {
|
||||
const result = resolveEncounter(input);
|
||||
if (result.success) {
|
||||
expect(result.survivorStateChange).toBeNull();
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('state change from field always matches survivor current state', () => {
|
||||
fc.assert(
|
||||
fc.property(arbInput(), (input) => {
|
||||
const result = resolveEncounter(input);
|
||||
if (result.survivorStateChange) {
|
||||
expect(result.survivorStateChange.from).toBe(input.survivor.state);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('logText is always a non-empty string', () => {
|
||||
fc.assert(
|
||||
fc.property(arbInput(), (input) => {
|
||||
const result = resolveEncounter(input);
|
||||
expect(typeof result.logText).toBe('string');
|
||||
expect(result.logText.length).toBeGreaterThan(0);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('higher difficulty never increases success rate (monte carlo, large sample)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
arbSeed,
|
||||
arbEncounter(),
|
||||
arbStats,
|
||||
(seed, encounter, stats) => {
|
||||
const wins = (difficulty: number) =>
|
||||
Array.from({ length: 40 }, (_, i) =>
|
||||
resolveEncounter(
|
||||
makeInput({ seed: `${seed}-${i}`, difficulty, encounter, survivor: {
|
||||
id: '00000000-0000-4000-a000-000000000002',
|
||||
state: 'active',
|
||||
stats,
|
||||
perkSlots: [],
|
||||
hookCount: 0,
|
||||
} })
|
||||
).success
|
||||
).filter(Boolean).length;
|
||||
|
||||
const easy = wins(1);
|
||||
const hard = wins(3);
|
||||
// Over 40 trials, difficulty 3 should not outperform difficulty 1 by more than 10 wins
|
||||
expect(hard - easy).toBeLessThanOrEqual(10);
|
||||
}
|
||||
),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
|
||||
it('all modifiersApplied entries reference perks in the input perkSlots', () => {
|
||||
const arbPerk: fc.Arbitrary<Perk> = fc.record({
|
||||
id: fc.uuid(),
|
||||
key: fc.string({ minLength: 1 }),
|
||||
name: fc.string({ minLength: 1 }),
|
||||
description: fc.string(),
|
||||
tags: fc.array(fc.string()),
|
||||
modifiers: fc.array(
|
||||
fc.record({
|
||||
target: fc.constantFrom('successChance' as const),
|
||||
type: fc.constantFrom('additive' as const, 'multiplicative' as const),
|
||||
amount: fc.float({ min: Math.fround(-0.3), max: Math.fround(0.3), noNaN: true }),
|
||||
condition: fc.option(
|
||||
fc.record({ encounterTags: fc.array(fc.constantFrom('generator', 'totem'), { minLength: 1 }) }),
|
||||
{ nil: undefined }
|
||||
),
|
||||
}),
|
||||
{ maxLength: 3 }
|
||||
),
|
||||
});
|
||||
|
||||
fc.assert(
|
||||
fc.property(
|
||||
arbInput().chain((base) =>
|
||||
fc.array(arbPerk, { maxLength: 4 }).map((perks) => ({
|
||||
...base,
|
||||
survivor: { ...base.survivor, perkSlots: perks },
|
||||
}))
|
||||
),
|
||||
(input) => {
|
||||
const result = resolveEncounter(input);
|
||||
const perkKeys = new Set(input.survivor.perkSlots.map((p) => p.key));
|
||||
for (const mod of result.modifiersApplied) {
|
||||
expect(perkKeys.has(mod.perkKey)).toBe(true);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Monte Carlo balance snapshot ─────────────────────────────────────────────
|
||||
|
||||
describe('encounter resolver — Monte Carlo balance snapshot', () => {
|
||||
const RUNS = 2000;
|
||||
|
||||
function winRate(overrides: Partial<ResolverInput>): number {
|
||||
return (
|
||||
Array.from({ length: RUNS }, (_, i) =>
|
||||
resolveEncounter(makeInput({ seed: `mc-${i}`, ...overrides })).success
|
||||
).filter(Boolean).length / RUNS
|
||||
);
|
||||
}
|
||||
|
||||
it('difficulty 1, objectives 5, p=0.5 → win rate 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);
|
||||
});
|
||||
});
|
||||
156
libs/mission-logic/src/lib/encounter-resolver.ts
Normal file
156
libs/mission-logic/src/lib/encounter-resolver.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import seedrandom = require('seedrandom');
|
||||
import type {
|
||||
EncounterDefinition,
|
||||
EncounterResult,
|
||||
ModifierApplication,
|
||||
Perk,
|
||||
SurvivorState,
|
||||
SurvivorStats,
|
||||
} from '@fog-explorer/api-interfaces';
|
||||
|
||||
export interface ResolverInput {
|
||||
seed: string;
|
||||
missionId: string;
|
||||
tickIndex: number;
|
||||
difficulty: number;
|
||||
encounter: EncounterDefinition;
|
||||
survivor: {
|
||||
id: string;
|
||||
state: SurvivorState;
|
||||
stats: SurvivorStats;
|
||||
perkSlots: Perk[];
|
||||
hookCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Probability adjustments per stat point and per difficulty level.
|
||||
const OBJECTIVES_BONUS = 0.02;
|
||||
const ALTRUISM_BONUS = 0.02;
|
||||
const DIFFICULTY_PENALTY = 0.12;
|
||||
|
||||
// Tag that enables the altruism stat bonus.
|
||||
const ALTRUISM_TAG = 'altruistic';
|
||||
|
||||
// Injury probability floor/ceiling when active survivor fails.
|
||||
const INJURY_CHANCE_BASE = 0.8;
|
||||
const INJURY_CHANCE_FLOOR = 0.2;
|
||||
const SURVIVAL_INJURY_REDUCTION = 0.05;
|
||||
|
||||
export function resolveEncounter(input: ResolverInput): EncounterResult {
|
||||
const rng = seedrandom(input.seed);
|
||||
|
||||
let probability = input.encounter.baseProbability;
|
||||
|
||||
probability -= (input.difficulty - 1) * DIFFICULTY_PENALTY;
|
||||
probability += input.survivor.stats.objectives * OBJECTIVES_BONUS;
|
||||
|
||||
if (input.encounter.tags.includes(ALTRUISM_TAG)) {
|
||||
probability += input.survivor.stats.altruism * ALTRUISM_BONUS;
|
||||
}
|
||||
|
||||
const modifiersApplied = applyPerkModifiers(probability, input);
|
||||
probability = modifiersApplied.adjusted;
|
||||
const appliedList = modifiersApplied.applied;
|
||||
|
||||
probability = Math.max(0, Math.min(1, probability));
|
||||
|
||||
const success = rng() < probability;
|
||||
|
||||
const survivorStateChange = success
|
||||
? null
|
||||
: computeStateChange(rng, input.survivor.state, input.survivor.stats.survival, input.survivor.hookCount);
|
||||
|
||||
return {
|
||||
missionId: input.missionId,
|
||||
survivorId: input.survivor.id,
|
||||
encounterKey: input.encounter.key,
|
||||
tickIndex: input.tickIndex,
|
||||
seed: input.seed,
|
||||
success,
|
||||
survivorStateChange,
|
||||
modifiersApplied: appliedList,
|
||||
logText: buildLogText(input.encounter.key, success, survivorStateChange),
|
||||
};
|
||||
}
|
||||
|
||||
function applyPerkModifiers(
|
||||
startProbability: number,
|
||||
input: ResolverInput
|
||||
): { adjusted: number; applied: ModifierApplication[] } {
|
||||
let probability = startProbability;
|
||||
const applied: ModifierApplication[] = [];
|
||||
|
||||
for (const perk of input.survivor.perkSlots) {
|
||||
for (const mod of perk.modifiers) {
|
||||
if (mod.target !== 'successChance') continue;
|
||||
|
||||
if (mod.condition) {
|
||||
const tagMatch = mod.condition.encounterTags.some((t) =>
|
||||
input.encounter.tags.includes(t)
|
||||
);
|
||||
if (!tagMatch) continue;
|
||||
}
|
||||
|
||||
if (mod.type === 'additive') {
|
||||
probability += mod.amount;
|
||||
} else {
|
||||
probability *= 1 + mod.amount;
|
||||
}
|
||||
|
||||
applied.push({
|
||||
perkKey: perk.key,
|
||||
target: mod.target,
|
||||
type: mod.type,
|
||||
effectiveAmount: mod.amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { adjusted: probability, applied };
|
||||
}
|
||||
|
||||
function computeStateChange(
|
||||
rng: seedrandom.PRNG,
|
||||
currentState: SurvivorState,
|
||||
survivalStat: number,
|
||||
hookCount: number
|
||||
): EncounterResult['survivorStateChange'] {
|
||||
if (currentState === 'active') {
|
||||
const injuryChance = Math.max(
|
||||
INJURY_CHANCE_FLOOR,
|
||||
INJURY_CHANCE_BASE - survivalStat * SURVIVAL_INJURY_REDUCTION
|
||||
);
|
||||
if (rng() < injuryChance) {
|
||||
return { from: 'active', to: 'injured' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentState === 'injured') {
|
||||
return { from: 'injured', to: 'downed' };
|
||||
}
|
||||
|
||||
if (currentState === 'downed' && hookCount >= 2) {
|
||||
return { from: 'downed', to: 'sacrificed' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const STATE_CHANGE_TEXT: Record<string, string> = {
|
||||
'active->injured': 'Survivor was injured.',
|
||||
'injured->downed': 'Survivor was downed.',
|
||||
'downed->sacrificed': 'Survivor was sacrificed.',
|
||||
};
|
||||
|
||||
function buildLogText(
|
||||
encounterKey: string,
|
||||
success: boolean,
|
||||
stateChange: EncounterResult['survivorStateChange']
|
||||
): string {
|
||||
const outcome = success ? 'succeeded' : 'failed';
|
||||
const base = `${encounterKey}: ${outcome}.`;
|
||||
if (!stateChange) return base;
|
||||
const extra = STATE_CHANGE_TEXT[`${stateChange.from}->${stateChange.to}`];
|
||||
return extra ? `${base} ${extra}` : base;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { missionLogic } from './mission-logic';
|
||||
|
||||
describe('missionLogic', () => {
|
||||
it('should work', () => {
|
||||
expect(missionLogic()).toEqual('mission-logic');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
export function missionLogic(): string {
|
||||
return 'mission-logic';
|
||||
}
|
||||
1
nx.json
1
nx.json
@@ -50,7 +50,6 @@
|
||||
"plugin": "@nx/vitest",
|
||||
"options": {
|
||||
"testTargetName": "test",
|
||||
"ciTargetName": "test-ci",
|
||||
"testMode": "watch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
171
pnpm-lock.yaml
generated
171
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user