Initialize environment configuration and enhance API logging

- Added a new .env file for environment variables including database and Redis configurations.
- Updated CLAUDE.md with hard rules for development practices.
- Enhanced package.json with new scripts for development and infrastructure management.
- Integrated Pino for structured logging in the API, replacing the default NestJS logger.
- Implemented OpenTelemetry for tracing and monitoring in the API.
- Added durationMinutes field to the Mission model in Prisma schema and created corresponding migration.
- Updated missions controller and service to handle mission duration and abandonment logic.
- Introduced new logger module for consistent logging across the application.
This commit is contained in:
Maurycy
2026-05-11 08:38:19 +00:00
parent 21f1a5319f
commit 0031ef0a8f
107 changed files with 3948 additions and 725 deletions

View File

@@ -23,6 +23,12 @@
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "apps/overlay/src/environments/environment.ts",
"with": "apps/overlay/src/environments/environment.prod.ts"
}
],
"budgets": [
{
"type": "initial",

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -4,10 +4,13 @@ import {
} from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './ebs/auth.interceptor';
import { EBS_BASE_URL } from './ebs/ebs-api.service';
import { environment } from '../environments/environment';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(withInterceptors([authInterceptor])),
{ provide: EBS_BASE_URL, useValue: environment.ebsBaseUrl },
],
};

View File

@@ -45,17 +45,17 @@ describe('EbsApiService', () => {
});
describe('startMission', () => {
it('POSTs to /missions/start with difficulty', () => {
service.startMission({ difficulty: 2 }).subscribe({ error: () => undefined });
it('POSTs to /missions/start with difficulty and durationMinutes', () => {
service.startMission({ difficulty: 2, durationMinutes: 20 }).subscribe({ error: () => undefined });
const req = controller.expectOne(`${BASE}/missions/start`);
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ difficulty: 2 });
expect(req.request.body).toEqual({ difficulty: 2, durationMinutes: 20 });
req.flush({ bad: 'shape' });
});
it('throws ZodError for invalid difficulty before sending request', () => {
expect(() => service.startMission({ difficulty: 99 })).toThrow();
it('throws ZodError for invalid request before sending', () => {
expect(() => service.startMission({ difficulty: 99, durationMinutes: 20 })).toThrow();
controller.expectNone(`${BASE}/missions/start`);
});
});

View File

@@ -5,7 +5,6 @@ import {
ChoosePerkRequestSchema,
MissionStateResponse,
MissionStateResponseSchema,
MissionSchema,
StartMissionRequest,
StartMissionRequestSchema,
SurvivorSchema,
@@ -25,11 +24,21 @@ export class EbsApiService {
.pipe(map((body) => MissionStateResponseSchema.parse(body)));
}
startMission(req: StartMissionRequest) {
startMission(req: StartMissionRequest): Observable<NonNullable<MissionStateResponse>> {
StartMissionRequestSchema.parse(req);
return this.http
.post(`${this.baseUrl}/missions/start`, req)
.pipe(map((body) => MissionSchema.parse(body)));
.pipe(
map((body) => {
const parsed = MissionStateResponseSchema.parse(body);
if (!parsed) throw new Error('Unexpected null from /missions/start');
return parsed;
}),
);
}
abandonMission(): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/missions/abandon`, {});
}
choosePerk(req: ChoosePerkRequest) {

View File

@@ -1,8 +1,10 @@
import { inject, Injectable, DestroyRef, signal } from '@angular/core';
import { effect, inject, Injectable, DestroyRef, signal, untracked } from '@angular/core';
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
import { EbsApiService } from '../ebs/ebs-api.service';
import { TwitchAuthService } from '../twitch/twitch-auth.service';
const POLL_INTERVAL_MS = 20_000;
@Injectable({ providedIn: 'root' })
export class MissionStateStore {
private readonly ebs = inject(EbsApiService);
@@ -11,11 +13,30 @@ export class MissionStateStore {
private readonly _state = signal<MissionStateResponse>(null);
private readonly _loading = signal(false);
private readonly _starting = signal(false);
private readonly _error = signal<unknown>(null);
private readonly _initializing = signal(true);
readonly state = this._state.asReadonly();
readonly loading = this._loading.asReadonly();
readonly starting = this._starting.asReadonly();
readonly error = this._error.asReadonly();
readonly initializing = this._initializing.asReadonly();
private pollTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
// Poll while the mission is active and the overlay is visible.
// PubSub is the primary update path; polling reconciles missed messages.
effect(() => {
const shouldPoll =
this._state()?.mission.status === 'active' &&
this.authService.isVisible();
untracked(() => (shouldPoll ? this.startPolling() : this.stopPolling()));
});
this.destroyRef.onDestroy(() => this.stopPolling());
}
init(): void {
this.fetchState();
@@ -27,6 +48,31 @@ export class MissionStateStore {
this.fetchState();
}
abandonMission(): void {
if (this._starting()) return;
this._error.set(null);
this.ebs.abandonMission().subscribe({
next: () => this._state.set(null),
error: (err) => this._error.set(err),
});
}
startMission(difficulty: 1 | 2 | 3, durationMinutes: 10 | 20 | 30, characterName?: string): void {
if (this._starting()) return;
this._starting.set(true);
this._error.set(null);
this.ebs.startMission({ difficulty, durationMinutes, characterName }).subscribe({
next: (state) => {
this._state.set(state);
this._starting.set(false);
},
error: (err) => {
this._starting.set(false);
this._error.set(err);
},
});
}
private fetchState(): void {
if (!this.authService.auth()) return;
@@ -35,15 +81,28 @@ export class MissionStateStore {
next: (state) => {
this._state.set(state);
this._loading.set(false);
this._initializing.set(false);
this._error.set(null);
},
error: (err) => {
this._loading.set(false);
this._initializing.set(false);
this._error.set(err);
},
});
}
private startPolling(): void {
if (this.pollTimer !== null) return;
this.pollTimer = setInterval(() => this.fetchState(), POLL_INTERVAL_MS);
}
private stopPolling(): void {
if (this.pollTimer === null) return;
clearInterval(this.pollTimer);
this.pollTimer = null;
}
private subscribePubSub(): void {
if (!window.Twitch?.ext) return;

View File

@@ -0,0 +1,54 @@
:host { display: block; }
.panel {
width: 290px;
min-height: 72px;
background: var(--fog-bg);
padding: 12px 16px;
display: flex;
align-items: center;
gap: 14px;
cursor: pointer;
border-top: 1px solid var(--fog-border);
animation: slide-up 0.22s ease-out;
}
@keyframes slide-up {
from { transform: translateY(8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.glyph {
font-family: var(--font-mono);
font-size: 22px;
flex-shrink: 0;
line-height: 1;
color: var(--fog-amber);
}
.glyph.injury { color: var(--fog-amber-dim); }
.glyph.sacrifice { color: var(--fog-red); }
.text {
flex: 1;
overflow: hidden;
}
.event-label {
font-family: var(--font-mono);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--fog-text-dim);
}
.event-desc {
font-family: var(--font-mono);
font-size: 12px;
color: var(--fog-text);
line-height: 1.4;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,8 @@
<div class="panel" role="button" tabindex="0"
(click)="dismissed.emit()" (keyup.enter)="dismissed.emit()">
<span class="glyph" [class]="event().type">{{ meta().glyph }}</span>
<div class="text">
<div class="event-label">{{ meta().label }}</div>
<div class="event-desc">{{ event().description }}</div>
</div>
</div>

View File

@@ -1,52 +1,27 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
export interface AmbientEventData {
type: 'injury' | 'sacrifice' | 'mission-complete' | 'perk-acquired';
description: string;
}
const EVENT_META: Record<AmbientEventData['type'], { glyph: string; label: string }> = {
'injury': { glyph: '◈', label: 'Injured' },
'sacrifice': { glyph: '✕', label: 'Sacrificed' },
'mission-complete':{ glyph: '◆', label: 'Mission complete' },
'perk-acquired': { glyph: '⬡', label: 'Perk acquired' },
};
@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>
`,
templateUrl: './ambient-event.component.html',
styleUrl: './ambient-event.component.css',
})
export class AmbientEventComponent {
event = input.required<AmbientEventData>();
dismissed = output<void>();
protected meta = computed(() => EVENT_META[this.event().type]);
}

View File

@@ -0,0 +1,260 @@
:host { display: block; }
.panel {
position: relative;
width: 320px;
max-height: 440px;
background: var(--fog-bg-deep);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Header ── */
.header {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 40px 12px 16px;
border-bottom: 1px solid var(--fog-border);
}
.avatar {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 50%;
background: #0e1116;
border: 2px solid var(--fog-amber);
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.3s ease;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
object-position: top center;
}
.avatar .avatar-initials {
font-family: var(--font-mono);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.06em;
color: #8a9ab8;
line-height: 1;
}
.header-info {
flex: 1;
min-width: 0;
}
.survivor-name {
font-family: var(--font-serif);
font-size: 20px;
font-weight: 700;
color: #e8eaed;
letter-spacing: 0.015em;
line-height: 1.1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.survivor-state {
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--fog-amber);
margin-top: 2px;
}
.survivor-state.injured { color: var(--fog-amber-dim); }
.survivor-state.downed { color: var(--fog-red); }
.survivor-state.sacrificed { color: var(--fog-red); }
.close-btn {
position: absolute;
top: 10px;
right: 10px;
width: 28px;
height: 28px;
background: none;
border: 1px solid var(--fog-border);
color: var(--fog-text-dim);
font-size: 14px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s, border-color 0.15s;
}
.close-btn:hover {
color: var(--fog-text);
border-color: rgba(255,255,255,0.15);
}
.close-btn:focus-visible {
outline: 2px solid var(--fog-amber);
outline-offset: 2px;
}
/* ── Perks ── */
.perk-slots {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
.perk-chip {
font-family: var(--font-mono);
font-size: 9.5px;
background: rgba(232, 165, 71, 0.10);
color: var(--fog-amber);
border: 1px solid rgba(232, 165, 71, 0.20);
padding: 2px 7px;
letter-spacing: 0.04em;
}
.perk-empty {
font-family: var(--font-mono);
font-size: 10px;
color: var(--fog-text-faint);
font-style: italic;
}
/* ── Mission strip ── */
.mission-strip {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid var(--fog-border);
}
.difficulty-pips {
display: flex;
gap: 3px;
}
.pip {
width: 6px;
height: 6px;
background: var(--fog-amber);
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
}
.pip.dim { background: var(--fog-text-faint); }
.mission-label {
font-family: var(--font-mono);
font-size: 9.5px;
color: var(--fog-text-dim);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.tick-counter {
font-family: var(--font-mono);
font-size: 10px;
color: var(--fog-text-dim);
margin-left: auto;
}
/* ── Log ── */
.log {
flex: 1;
overflow: hidden;
padding: 10px 16px 12px;
display: flex;
flex-direction: column;
gap: 5px;
}
.log-entry {
font-family: var(--font-mono);
font-size: 10.5px;
line-height: 1.5;
color: var(--fog-text);
transition: opacity 0.2s;
}
.log-glyph {
margin-right: 5px;
font-size: 9px;
}
.log-entry.success .log-glyph { color: var(--fog-green); }
.log-entry.failure .log-glyph { color: var(--fog-red); }
/* ── Abandon row ── */
.abandon-row {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-bottom: 1px solid var(--fog-border);
}
.abandon-btn {
background: none;
border: 1px solid rgba(180, 60, 60, 0.3);
color: rgba(180, 60, 60, 0.6);
font-family: var(--font-mono);
font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 4px 10px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.abandon-btn:hover, .abandon-btn:focus-visible {
border-color: var(--fog-red);
color: var(--fog-red);
outline: none;
}
.abandon-confirm-label {
font-family: var(--font-mono);
font-size: 9.5px;
color: var(--fog-red);
flex: 1;
font-style: italic;
}
.abandon-confirm-btn {
background: rgba(180, 60, 60, 0.15);
border: 1px solid var(--fog-red);
color: var(--fog-red);
font-family: var(--font-mono);
font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 4px 10px;
cursor: pointer;
}
.abandon-confirm-btn:focus-visible { outline: 2px solid var(--fog-red); }
.abandon-cancel-btn {
background: none;
border: 1px solid var(--fog-border);
color: var(--fog-text-dim);
font-family: var(--font-mono);
font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 4px 10px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.abandon-cancel-btn:hover, .abandon-cancel-btn:focus-visible {
border-color: rgba(255,255,255,0.15);
color: var(--fog-text);
outline: none;
}

View File

@@ -0,0 +1,72 @@
<div class="panel">
<button class="close-btn" (click)="panelClose.emit()" aria-label="Close"></button>
@if (survivor(); as s) {
<div class="header">
<div
class="avatar"
[style.border-color]="survivorStateColor()"
aria-hidden="true"
>
@if (avatarSrc() && !imgError()) {
<img
class="avatar-img"
[src]="avatarSrc()!"
[alt]="s.name"
(error)="imgError.set(true)"
/>
} @else {
<span class="avatar-initials">{{ initials() }}</span>
}
</div>
<div class="header-info">
<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-chip">{{ perk.name }}</span>
} @empty {
<span class="perk-empty">No perks equipped</span>
}
</div>
</div>
</div>
}
@if (mission(); as m) {
<div class="mission-strip">
<div class="difficulty-pips" aria-label="Difficulty {{ m.difficulty }} of 3">
@for (pip of difficultyPips(); track $index) {
<div class="pip" [class.dim]="!pip"></div>
}
</div>
<span class="mission-label">Mission</span>
<span class="tick-counter">T+{{ m.tickIndex }}</span>
</div>
}
@if (missionState().mission.status === 'active') {
<div class="abandon-row">
@if (!confirmingAbandon()) {
<button class="abandon-btn" (click)="confirmingAbandon.set(true)">Abandon mission</button>
} @else {
<span class="abandon-confirm-label">Leave the fog?</span>
<button class="abandon-confirm-btn" (click)="abandonRequest.emit()">Confirm</button>
<button class="abandon-cancel-btn" (click)="confirmingAbandon.set(false)">Cancel</button>
}
</div>
}
<div class="log" role="log" aria-label="Mission log" aria-live="polite">
@for (entry of logEntries(); track entry.tickIndex; let i = $index) {
<div
class="log-entry"
[class.success]="entry.success"
[class.failure]="!entry.success"
[style.opacity]="entryOpacity(i)"
>
<span class="log-glyph">{{ entry.success ? '▶' : '▷' }}</span>{{ entry.logText }}
</div>
}
</div>
</div>

View File

@@ -4,126 +4,65 @@ import {
computed,
input,
output,
signal,
} from '@angular/core';
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
import { avatarUrl, survivorInitials } from './survivor-initials';
/** Most-recent entry is index 0; older entries fade toward 0 opacity. */
const LOG_OPACITY_STEP = 0.18;
const LOG_MAX_VISIBLE = 8;
@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>
`,
templateUrl: './expanded-panel.component.html',
styleUrl: './expanded-panel.component.css',
})
export class ExpandedPanelComponent {
missionState = input.required<NonNullable<MissionStateResponse>>();
close = output<void>();
panelClose = output<void>();
abandonRequest = output<void>();
protected readonly confirmingAbandon = signal(false);
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)
protected initials = computed(() => {
const name = this.missionState().survivors[0]?.name ?? '';
return name ? survivorInitials(name) : '?';
});
protected avatarSrc = computed(() => {
const name = this.missionState().survivors[0]?.name ?? '';
return avatarUrl(name);
});
protected readonly imgError = signal(false);
protected survivorStateColor = computed(() => {
const state = this.missionState().survivors[0]?.state ?? 'idle';
const map: Record<string, string> = {
active: 'var(--fog-amber)',
injured: 'var(--fog-amber-dim)',
downed: 'var(--fog-red)',
sacrificed:'var(--fog-red)',
idle: 'var(--fog-text-dim)',
};
return map[state] ?? 'var(--fog-text-dim)';
});
protected logEntries = computed(() =>
this.missionState().recentLog.slice(0, LOG_MAX_VISIBLE)
);
protected difficultyPips = computed(() => {
const d = this.missionState().mission.difficulty;
return [1, 2, 3].map((n) => n <= d);
});
protected entryOpacity(index: number): number {
return Math.max(0.15, 1 - index * LOG_OPACITY_STEP);
}
}

View File

@@ -0,0 +1,93 @@
:host { display: block; }
.panel {
display: flex;
align-items: center;
gap: 10px;
width: 290px;
height: 56px;
background: var(--fog-bg);
padding: 0 12px 0 8px;
}
.lantern-wrap {
flex-shrink: 0;
position: relative;
width: 44px;
height: 44px;
}
.lantern {
width: 100%;
height: 100%;
border-radius: 50%;
background: #12151a;
border: 2.5px solid var(--fog-amber);
cursor: pointer;
transition: border-color 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.lantern:focus-visible {
outline: 2px solid var(--fog-amber);
outline-offset: 3px;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
object-position: top center;
pointer-events: none;
}
.avatar-initials {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.06em;
color: #8a9ab8;
pointer-events: none;
line-height: 1;
}
.state-badge {
position: absolute;
bottom: -3px;
right: -3px;
width: 15px;
height: 15px;
border-radius: 50%;
background: rgba(15, 18, 22, 0.95);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: 8px;
pointer-events: none;
}
.ticker {
flex: 1;
overflow: hidden;
}
.log-line {
display: block;
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 400;
color: var(--fog-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
}
.log-line.idle {
color: var(--fog-text-dim);
font-style: italic;
}

View File

@@ -0,0 +1,33 @@
<div class="panel">
<div class="lantern-wrap">
<button
class="lantern"
[style.border-color]="stateMeta().color"
(click)="lanternClick.emit()"
[attr.aria-label]="'Open details for ' + (missionState().survivors[0]?.name ?? 'survivor')"
>
@if (avatarSrc() && !imgError()) {
<img
class="avatar-img"
[src]="avatarSrc()!"
[alt]="missionState().survivors[0]?.name ?? ''"
(error)="imgError.set(true)"
/>
} @else {
<span class="avatar-initials">{{ initials() }}</span>
}
</button>
<span
class="state-badge"
[style.color]="stateMeta().color"
aria-hidden="true"
>{{ stateMeta().label }}</span>
</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>

View File

@@ -4,75 +4,53 @@ import {
computed,
input,
output,
signal,
} from '@angular/core';
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
import { avatarUrl, survivorInitials } from './survivor-initials';
/**
* State → ring colour + shape indicator.
* Colour alone is never the sole differentiator (accessibility requirement).
*/
const STATE_META: Record<string, { color: string; label: string }> = {
active: { color: 'var(--fog-amber)', label: '◆' },
injured: { color: 'var(--fog-amber-dim)', label: '◈' },
downed: { color: 'var(--fog-red)', label: '◇' },
sacrificed:{ color: 'var(--fog-red)', label: '✕' },
idle: { color: 'var(--fog-text-dim)', label: '○' },
};
@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>
`,
templateUrl: './minimised-panel.component.html',
styleUrl: './minimised-panel.component.css',
})
export class MinimisedPanelComponent {
missionState = input.required<NonNullable<MissionStateResponse>>();
lanternClick = output<void>();
protected survivorState = computed(
() => this.missionState().survivors[0]?.state ?? 'idle'
);
protected stateMeta = computed(() => {
const state = this.missionState().survivors[0]?.state ?? 'idle';
return STATE_META[state] ?? STATE_META['idle'];
});
protected initials = computed(() => {
const name = this.missionState().survivors[0]?.name ?? '';
return name ? survivorInitials(name) : '?';
});
protected avatarSrc = computed(() => {
const name = this.missionState().survivors[0]?.name ?? '';
return avatarUrl(name);
});
protected readonly imgError = signal(false);
protected latestLogLine = computed(() => {
const log = this.missionState().recentLog;
return log.length ? log[log.length - 1].logText : null;
return log.length ? log[0].logText : null;
});
}

View File

@@ -0,0 +1,244 @@
:host {
display: block;
position: fixed;
bottom: 16px;
left: 16px;
z-index: 9999;
}
/* ── Anonymous viewer: desaturated lantern, no ticker ── */
.anon-panel {
width: 290px;
height: 56px;
background: var(--fog-bg);
display: flex;
align-items: center;
padding: 0 12px 0 8px;
}
.lantern-desaturated {
width: 44px;
height: 44px;
border-radius: 50%;
background: #1a1c20;
border: 2.5px solid #3a3e48;
flex-shrink: 0;
}
.anon-label {
font-family: var(--font-mono);
font-size: 10px;
color: var(--fog-text-faint);
margin-left: 10px;
font-style: italic;
}
/* ── First-time / onboarding panel ── */
.onboarding-panel {
width: 290px;
min-height: 56px;
background: var(--fog-bg);
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 12px 16px;
gap: 8px;
border-left: 2px solid var(--fog-amber);
}
.onboarding-glyph {
font-family: var(--font-mono);
font-size: 18px;
color: var(--fog-amber);
opacity: 0.6;
flex-shrink: 0;
}
.onboarding-text {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fog-text-dim);
font-style: italic;
line-height: 1.4;
margin: 0;
}
.onboarding-outcome {
font-family: var(--font-mono);
font-size: 10px;
color: var(--fog-text-faint);
font-style: italic;
margin: 0 0 4px;
}
.onboarding-error {
font-family: var(--font-mono);
font-size: 10px;
color: var(--fog-red);
margin: 4px 0 0;
}
.onboarding-glyph--pulse {
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
.difficulty-row {
display: flex;
gap: 6px;
margin-top: 10px;
}
.diff-btn {
flex: 1;
background: transparent;
border: 1px solid var(--fog-amber-dim);
color: var(--fog-text-dim);
font-family: var(--font-mono);
font-size: 10px;
padding: 6px 4px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
transition: border-color 0.15s, color 0.15s;
}
.diff-btn:hover, .diff-btn:focus-visible {
border-color: var(--fog-amber);
color: var(--fog-amber);
outline: none;
}
.diff-pips {
font-size: 9px;
letter-spacing: 1px;
color: var(--fog-amber);
opacity: 0.7;
}
.diff-btn:hover .diff-pips, .diff-btn:focus-visible .diff-pips {
opacity: 1;
}
.diff-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.back-btn {
background: none;
border: none;
color: var(--fog-text-faint);
font-family: var(--font-mono);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.05em;
cursor: pointer;
padding: 4px 0 0;
align-self: flex-start;
transition: color 0.15s;
}
.back-btn:hover, .back-btn:focus-visible {
color: var(--fog-text-dim);
outline: none;
}
.onboarding-panel--loading {
flex-direction: row;
align-items: center;
}
/* ── Character picker ── */
.character-search-row {
width: 100%;
}
.character-filter {
width: 100%;
box-sizing: border-box;
background: transparent;
border: 1px solid var(--fog-amber-dim);
color: var(--fog-text-dim);
font-family: var(--font-mono);
font-size: 10px;
padding: 5px 8px;
outline: none;
transition: border-color 0.15s;
}
.character-filter::placeholder {
color: var(--fog-text-faint);
font-style: italic;
}
.character-filter:focus {
border-color: var(--fog-amber);
}
.character-list {
width: 100%;
max-height: 180px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
scrollbar-width: thin;
scrollbar-color: var(--fog-amber-dim) transparent;
}
.character-btn {
width: 100%;
background: transparent;
border: none;
border-left: 2px solid transparent;
color: var(--fog-text-faint);
font-family: var(--font-mono);
font-size: 10px;
padding: 3px 8px;
text-align: left;
cursor: pointer;
transition: color 0.12s, border-color 0.12s;
display: flex;
align-items: center;
gap: 7px;
}
.character-thumb {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
object-fit: cover;
object-position: top center;
opacity: 0.7;
transition: opacity 0.12s;
}
.character-btn:hover .character-thumb,
.character-btn:focus-visible .character-thumb {
opacity: 1;
}
.character-btn:hover,
.character-btn:focus-visible {
color: var(--fog-text-dim);
border-left-color: var(--fog-amber-dim);
outline: none;
}
.character-btn--random {
color: var(--fog-amber);
opacity: 0.75;
font-style: italic;
border-bottom: 1px solid var(--fog-amber-dim);
margin-bottom: 4px;
padding-bottom: 6px;
}
.character-btn--random:hover,
.character-btn--random:focus-visible {
opacity: 1;
border-left-color: transparent;
}
.character-chosen {
color: var(--fog-amber);
}
/* ── Backdrop dim when expanded ── */
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: -1;
animation: fade-in 0.15s ease;
border: none;
cursor: default;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}

View File

@@ -0,0 +1,132 @@
@if (authService.auth()) {
@if (!authService.isLoggedIn()) {
<div class="anon-panel">
<div class="lantern-desaturated"></div>
<span class="anon-label">Sign in to join the expedition</span>
</div>
} @else if (store.initializing()) {
<div class="onboarding-panel onboarding-panel--loading">
<span class="onboarding-glyph onboarding-glyph--pulse"></span>
<p class="onboarding-text">Reading the fog&hellip;</p>
</div>
} @else if (showSummary()) {
<app-summary-panel
[missionState]="store.state()!"
(continueExpedition)="onContinueAfterSummary()"
/>
} @else if (!store.state() || !missionIsActive()) {
@if (store.starting()) {
<div class="onboarding-panel onboarding-panel--loading">
<span class="onboarding-glyph onboarding-glyph--pulse"></span>
<p class="onboarding-text">Entering the fog&hellip;</p>
</div>
} @else {
<div class="onboarding-panel">
@if (terminalOutcome()) {
<p class="onboarding-outcome">{{ terminalOutcome() }}</p>
}
@if (store.error()) {
<p class="onboarding-error">Something went wrong. Try again.</p>
}
@if (!selectedCharacter()) {
<p class="onboarding-text">Who enters the fog?</p>
<div class="character-search-row">
<input
class="character-filter"
type="text"
placeholder="Filter survivors…"
[ngModel]="characterFilter()"
(ngModelChange)="characterFilter.set($event)"
aria-label="Filter survivor list"
/>
</div>
<div class="character-list" role="listbox" aria-label="Survivor selection">
@if (!characterFilter()) {
<button
class="character-btn character-btn--random"
role="option"
(click)="onSelectRandomCharacter()"
aria-label="Choose a random survivor"
>
◈ Random
</button>
}
@for (name of filteredSurvivors(); track name) {
<button
class="character-btn"
role="option"
(click)="onSelectCharacter(name)"
[attr.aria-label]="name"
>
@if (avatarUrl(name); as src) {
<img class="character-thumb" [src]="src" [alt]="name" />
}
{{ name }}
</button>
}
</div>
} @else if (!selectedDifficulty()) {
<p class="onboarding-text">
<span class="character-chosen">{{ selectedCharacter() }}</span>
enters the fog.
</p>
<p class="onboarding-text">Choose your path.</p>
<div class="difficulty-row">
<button class="diff-btn" (click)="onSelectDifficulty(1)" aria-label="Common expedition">
<span class="diff-pips"></span>
<span class="diff-label">Common</span>
</button>
<button class="diff-btn" (click)="onSelectDifficulty(2)" aria-label="Perilous expedition">
<span class="diff-pips">◆◆</span>
<span class="diff-label">Perilous</span>
</button>
<button class="diff-btn" (click)="onSelectDifficulty(3)" aria-label="Dire expedition">
<span class="diff-pips">◆◆◆</span>
<span class="diff-label">Dire</span>
</button>
</div>
<button class="back-btn" (click)="onBackToCharacter()">&#8592; Back</button>
} @else {
<p class="onboarding-text">How long will you endure?</p>
<div class="difficulty-row">
<button class="diff-btn" (click)="onSelectDuration(10)" aria-label="10 minute expedition">
<span class="diff-label">10 min</span>
</button>
<button class="diff-btn" (click)="onSelectDuration(20)" aria-label="20 minute expedition">
<span class="diff-label">20 min</span>
</button>
<button class="diff-btn" (click)="onSelectDuration(30)" aria-label="30 minute expedition">
<span class="diff-label">30 min</span>
</button>
</div>
<button class="back-btn" (click)="onBackToDifficulty()">&#8592; Back</button>
}
</div>
}
} @else {
@if (view() === 'expanded') {
<button class="backdrop" (click)="onLanternClick()" aria-label="Close panel"></button>
}
@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()!"
(panelClose)="onLanternClick()"
(abandonRequest)="onAbandon()"
/>
}
}
}
}

View File

@@ -28,7 +28,7 @@ class AmbientEventStub {
@Component({ selector: 'app-expanded-panel', standalone: true, template: '' })
class ExpandedPanelStub {
@Input() missionState!: NonNullable<MissionStateResponse>;
@Output() close = new EventEmitter<void>();
@Output() panelClose = new EventEmitter<void>();
}
const BASE = 'https://test.local';
@@ -193,7 +193,7 @@ describe('PanelShellComponent', () => {
// Emit close from the expanded stub
fixture.debugElement.query(By.directive(ExpandedPanelStub))
.componentInstance.close.emit();
.componentInstance.panelClose.emit();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
});

View File

@@ -1,6 +1,7 @@
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
inject,
@@ -8,15 +9,18 @@ import {
signal,
untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
import { SURVIVOR_NAMES } from '@fog-explorer/encounter-library';
import { avatarUrl } from './survivor-initials';
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';
import { SummaryPanelComponent } from './summary-panel.component';
type OverlayView = 'minimised' | 'ambient' | 'expanded';
@@ -45,76 +49,44 @@ function detectAmbientEvent(
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;
}
`],
imports: [FormsModule, MinimisedPanelComponent, AmbientEventComponent, ExpandedPanelComponent, SummaryPanelComponent],
templateUrl: './panel-shell.component.html',
styleUrl: './panel-shell.component.css',
})
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);
protected readonly selectedCharacter = signal<string | null>(null);
protected readonly selectedDifficulty = signal<1 | 2 | 3 | null>(null);
protected readonly characterFilter = signal('');
protected readonly survivorNames = SURVIVOR_NAMES;
protected readonly avatarUrl = avatarUrl;
protected readonly filteredSurvivors = computed(() => {
const q = this.characterFilter().toLowerCase().trim();
return q ? SURVIVOR_NAMES.filter((n) => n.toLowerCase().includes(q)) : SURVIVOR_NAMES;
});
protected readonly missionIsActive = computed(
() => this.store.state()?.mission.status === 'active',
);
protected readonly terminalOutcome = computed(() => {
const status = this.store.state()?.mission.status;
if (status === 'success') return 'The expedition is complete.';
if (status === 'sacrifice') return 'Taken by the fog.';
if (status === 'abandoned') return 'The expedition was abandoned.';
return null;
});
protected readonly summaryDismissed = signal(false);
protected readonly showSummary = computed(() => {
const status = this.store.state()?.mission.status;
return (status === 'success' || status === 'sacrifice') && !this.summaryDismissed();
});
private prevState: MissionStateResponse = null;
private ambientTimer: ReturnType<typeof setTimeout> | null = null;
@@ -134,6 +106,13 @@ export class PanelShellComponent implements OnInit {
const current = this.store.state();
untracked(() => this.handleStateChange(current));
});
// Reset summary gate when a fresh active mission arrives.
effect(() => {
if (this.store.state()?.mission.status === 'active') {
untracked(() => this.summaryDismissed.set(false));
}
});
}
ngOnInit(): void {
@@ -145,6 +124,49 @@ export class PanelShellComponent implements OnInit {
this.view.update((v) => (v === 'expanded' ? 'minimised' : 'expanded'));
}
protected onSelectCharacter(name: string): void {
this.selectedCharacter.set(name);
this.characterFilter.set('');
}
protected onSelectRandomCharacter(): void {
const names = SURVIVOR_NAMES;
const picked = names[Math.floor(Math.random() * names.length)];
this.selectedCharacter.set(picked);
this.characterFilter.set('');
}
protected onBackToCharacter(): void {
this.selectedCharacter.set(null);
this.selectedDifficulty.set(null);
this.characterFilter.set('');
}
protected onSelectDifficulty(difficulty: 1 | 2 | 3): void {
this.selectedDifficulty.set(difficulty);
}
protected onSelectDuration(durationMinutes: 10 | 20 | 30): void {
const difficulty = this.selectedDifficulty();
if (!difficulty) return;
this.store.startMission(difficulty, durationMinutes, this.selectedCharacter() ?? undefined);
}
protected onBackToDifficulty(): void {
this.selectedDifficulty.set(null);
}
protected onAbandon(): void {
this.store.abandonMission();
this.view.set('minimised');
}
protected onContinueAfterSummary(): void {
this.summaryDismissed.set(true);
this.selectedCharacter.set(null);
this.selectedDifficulty.set(null);
}
protected onAmbientDismiss(): void {
this.clearAmbientTimer();
this.view.set('minimised');

View File

@@ -0,0 +1,147 @@
:host { display: block; }
.panel {
width: 290px;
max-height: 480px;
background: var(--fog-bg);
border-left: 2px solid var(--fog-amber);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Outcome header ── */
.outcome-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px 12px;
border-bottom: 1px solid var(--fog-border);
flex-shrink: 0;
}
.outcome-glyph {
font-family: var(--font-mono);
font-size: 18px;
line-height: 1;
color: var(--fog-amber);
flex-shrink: 0;
}
.outcome-glyph.sacrifice { color: var(--fog-red); }
.outcome-heading {
font-family: var(--font-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--fog-text);
}
/* ── Stats ── */
.stats-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 8px 16px;
border-bottom: 1px solid var(--fog-border);
flex-shrink: 0;
}
.stat-label {
font-family: var(--font-mono);
font-size: 9.5px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fog-text-dim);
}
.stat-value {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fog-text);
}
/* ── Survivors ── */
.survivors {
display: flex;
flex-direction: column;
padding: 6px 0;
border-bottom: 1px solid var(--fog-border);
flex-shrink: 0;
}
.survivor-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 16px;
}
.survivor-name {
font-family: var(--font-serif);
font-size: 13px;
color: var(--fog-text);
}
.survivor-state {
font-family: var(--font-mono);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--fog-amber);
}
.survivor-state.injured { color: var(--fog-amber-dim); }
.survivor-state.downed { color: var(--fog-red); }
.survivor-state.sacrificed { color: var(--fog-red); }
/* ── Log ── */
.log {
flex: 1;
overflow-y: auto;
padding: 8px 16px;
display: flex;
flex-direction: column;
gap: 4px;
border-bottom: 1px solid var(--fog-border);
min-height: 0;
}
.log::-webkit-scrollbar { width: 3px; }
.log::-webkit-scrollbar-track { background: transparent; }
.log::-webkit-scrollbar-thumb { background: var(--fog-border); }
.log-entry {
font-family: var(--font-mono);
font-size: 10px;
line-height: 1.5;
color: var(--fog-text-dim);
}
.log-glyph {
margin-right: 5px;
font-size: 8px;
}
.log-entry.success .log-glyph { color: var(--fog-green); }
.log-entry.failure .log-glyph { color: var(--fog-red); opacity: 0.5; }
/* ── Continue button ── */
.continue-btn {
margin: 12px 16px;
padding: 8px 0;
background: transparent;
border: 1px solid var(--fog-amber-dim);
color: var(--fog-text-dim);
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
flex-shrink: 0;
}
.continue-btn:hover, .continue-btn:focus-visible {
border-color: var(--fog-amber);
color: var(--fog-amber);
outline: none;
}

View File

@@ -0,0 +1,34 @@
<div class="panel">
<div class="outcome-header">
<span class="outcome-glyph" [class]="outcome().glyphClass">{{ outcome().glyph }}</span>
<span class="outcome-heading">{{ outcome().heading }}</span>
</div>
<div class="stats-row">
<span class="stat-label">Survived</span>
<span class="stat-value">{{ ticks() }} tick{{ ticks() === 1 ? '' : 's' }}</span>
</div>
<div class="survivors">
@for (survivor of missionState().survivors; track survivor.id) {
<div class="survivor-row">
<span class="survivor-name">{{ survivor.name }}</span>
<span class="survivor-state" [class]="survivor.state">{{ survivor.state }}</span>
</div>
}
</div>
@if (missionState().recentLog.length) {
<div class="log" role="log" aria-label="Mission log">
@for (entry of missionState().recentLog; track entry.tickIndex) {
<div class="log-entry" [class.success]="entry.success" [class.failure]="!entry.success">
<span class="log-glyph">{{ entry.success ? '▶' : '▷' }}</span>{{ entry.logText }}
</div>
}
</div>
}
<button class="continue-btn" (click)="continueExpedition.emit()">
Begin new expedition
</button>
</div>

View File

@@ -0,0 +1,32 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
const OUTCOME: Record<string, { glyph: string; heading: string; glyphClass: string }> = {
success: { glyph: '◆', heading: 'Expedition complete', glyphClass: 'success' },
sacrifice: { glyph: '✕', heading: 'Taken by the fog', glyphClass: 'sacrifice' },
};
@Component({
selector: 'app-summary-panel',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './summary-panel.component.html',
styleUrl: './summary-panel.component.css',
})
export class SummaryPanelComponent {
missionState = input.required<NonNullable<MissionStateResponse>>();
continueExpedition = output<void>();
protected outcome = computed(() => {
const status = this.missionState().mission.status;
return OUTCOME[status] ?? { glyph: '○', heading: 'Expedition ended', glyphClass: '' };
});
protected ticks = computed(() => this.missionState().mission.tickIndex);
}

View File

@@ -0,0 +1,62 @@
export function survivorInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter((p) => /[A-Za-zÀ-ÿ]/.test(p[0]));
if (parts.length === 0) return '?';
if (parts.length === 1) return parts[0][0].toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
const SURVIVOR_AVATAR: Record<string, string> = {
'Dwight Fairfield': 'S01_DwightFairfield_Portrait.webp',
'Meg Thomas': 'S02_MegThomas_Portrait.webp',
'Claudette Morel': 'S03_ClaudetteMorel_Portrait.webp',
'Jake Park': 'S04_JakePark_Portrait.webp',
'Nea Karlsson': 'S05_NeaKarlsson_Portrait.webp',
'Laurie Strode': 'S06_LaurieStrode_Portrait.webp',
'Ace Visconti': 'S07_AceVisconti_Portrait.webp',
'Bill Overbeck': 'S08_BillOverbeck_Portrait.webp',
'Feng Min': 'S09_FengMin_Portrait.webp',
'David King': 'S10_DavidKing_Portrait.webp',
'Quentin Smith': 'S11_QuentinSmith_Portrait.webp',
'David Tapp': 'S12_DavidTapp_Portrait.webp',
'Kate Denson': 'S13_KateDenson_Portrait.webp',
'Adam Francis': 'S14_AdamFrancis_Portrait.webp',
'Jeff Johansen': 'S15_JeffJohansen_Portrait.webp',
'Jane Romero': 'S16_JaneRomero_Portrait.webp',
'Ash Williams': 'S17_AshWilliams_Portrait.webp',
'Nancy Wheeler': 'S18_NancyWheeler_Portrait.webp',
'Steve Harrington': 'S19_SteveHarrington_Portrait.webp',
'Yui Kimura': 'S20_YuiKimura_Portrait.webp',
'Zarina Kassir': 'S21_ZarinaKassir_Portrait.webp',
'Cheryl Mason': 'S22_CherylMason_Portrait.webp',
'Felix Richter': 'S23_FelixRichter_Portrait.webp',
'Élodie Rakoto': 'S24_ElodieRakoto_Portrait.webp',
'Yun-Jin Lee': 'S25_Yun-JinLee_Portrait.webp',
'Jill Valentine': 'S26_JillValentine_Portrait.webp',
'Leon S. Kennedy': 'S27_LeonScottKennedy_Portrait.webp',
'Mikaela Reid': 'S28_MikaelaReid_Portrait.webp',
'Jonah Vasquez': 'S29_JonahVasquez_Portrait.webp',
'Yoichi Asakawa': 'S30_YoichiAsakawa_Portrait.webp',
'Haddie Kaur': 'S31_HaddieKaur_Portrait.webp',
'Ada Wong': 'S32_AdaWong_Portrait.webp',
'Rebecca Chambers': 'S33_RebeccaChambers_Portrait.webp',
'Vittorio Toscano': 'S34_VittorioToscano_Portrait.webp',
'Thalita Lyra': 'S35_ThalitaLyra_Portrait.webp',
'Renato Lyra': 'S36_RenatoLyra_Portrait.webp',
'Gabriel Soma': 'S37_GabrielSoma_Portrait.webp',
'Nicolas Cage': 'S38_NicolasCage_Portrait.webp',
'Ellen Ripley': 'S39_EllenRipley_Portrait.webp',
'Alan Wake': 'S40_AlanWake_Portrait.webp',
'Sable Ward': 'S41_SableWard_Portrait.webp',
'Aestri Yazar': 'S42_TheTroupe_Portrait.webp',
'Lara Croft': 'S43_LaraCroft_Portrait.webp',
'Trevor Belmont': 'S44_TrevorBelmont_Portrait.webp',
'Taurie Cain': 'S45_TaurieCain_Portrait.webp',
'Rick Grimes': 'S46_RickGrimes_Portrait.webp',
'Michonne Grimes': 'S47_MichonneGrimes_Portrait.webp',
'Vee Boonyasak': 'S48_VeeBoonyasak_Portrait.webp',
};
export function avatarUrl(name: string): string | null {
const file = SURVIVOR_AVATAR[name];
return file ? `avatars/${file}` : null;
}

View File

@@ -0,0 +1,5 @@
export const environment = {
production: true,
// Production EBS API base URL for deployed overlay builds.
ebsBaseUrl: 'https://api.fog-expedition.example.com/api',
};

View File

@@ -0,0 +1,6 @@
export const environment = {
production: false,
// HTTP (not HTTPS) for local dev — no Caddy/TLS needed when the Twitch rig
// is not in the loop and the browser is not enforcing mixed-content rules.
ebsBaseUrl: 'http://localhost:3000/api',
};

View File

@@ -2,10 +2,17 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>overlay</title>
<title>Fog Expedition</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<!-- Cormorant: survivor names. JetBrains Mono: live log. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cormorant:wght@600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<app-root></app-root>

View File

@@ -1 +1,43 @@
/* You can add global styles to this file, and also import other style files */
/*
* Global reset and design tokens.
* Component styles live in their own files — only truly global rules here.
*/
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
/* Palette */
--fog-bg: rgba(15, 18, 22, 0.88);
--fog-bg-deep: rgba(15, 18, 22, 0.95);
--fog-surface: rgba(255, 255, 255, 0.04);
--fog-border: rgba(255, 255, 255, 0.06);
--fog-text: #c8ccd0;
--fog-text-dim: #6a7080;
--fog-text-faint:#3a3e48;
--fog-amber: #E8A547; /* active state, primary accent */
--fog-amber-dim: #B8842E; /* injured state */
--fog-red: #C03A3A; /* downed / sacrifice */
--fog-green: #4a8c6f; /* success outcome */
/* Typography */
--font-serif: 'Cormorant', Georgia, serif;
--font-mono: 'JetBrains Mono', 'IBM Plex Mono', 'Courier New', monospace;
--font-sans: system-ui, -apple-system, sans-serif;
}
body {
font-family: var(--font-sans);
background: transparent;
color: var(--fog-text);
-webkit-font-smoothing: antialiased;
}
button {
font-family: inherit;
}