first commit

This commit is contained in:
Hussar
2026-04-12 15:35:50 +00:00
commit 42d20cb0ed
80 changed files with 2210 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
FROM node:20-alpine AS build
WORKDIR /workspace
COPY package.json ./
RUN npm install
COPY . .
RUN npx nx build twitch-extension-panel
FROM nginx:alpine
COPY --from=build /workspace/dist/apps/twitch-extension-panel/browser /usr/share/nginx/html
EXPOSE 80

View File

@@ -0,0 +1,26 @@
{
"name": "twitch-extension-panel",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/twitch-extension-panel/src",
"projectType": "application",
"targets": {
"build": {
"executor": "@nx/angular:application",
"options": {
"outputPath": "dist/apps/twitch-extension-panel",
"index": "apps/twitch-extension-panel/src/index.html",
"browser": "apps/twitch-extension-panel/src/main.ts",
"tsConfig": "apps/twitch-extension-panel/tsconfig.app.json",
"assets": [],
"styles": ["apps/twitch-extension-panel/src/styles.css"]
}
},
"serve": {
"executor": "@nx/angular:dev-server",
"options": {
"buildTarget": "twitch-extension-panel:build"
}
}
},
"tags": ["scope:panel"]
}

View File

@@ -0,0 +1,28 @@
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { EncounterLogLine } from '@fog-explorer/api-interfaces';
@Component({
selector: 'fog-live-log',
standalone: true,
imports: [CommonModule],
template: `
<section class="panel-card">
<h3>Live Log</h3>
<div class="log-list">
<p *ngFor="let line of lines">#{{ line.sequence }} - {{ line.text }}</p>
</div>
</section>
`,
styles: [
`
.panel-card { padding: 0.75rem; background: #1a1e2a; border-radius: 8px; }
.log-list { max-height: 220px; overflow: auto; }
p { margin: 0 0 0.5rem; font-size: 0.9rem; }
`
]
})
export class LiveLogComponent {
@Input({ required: true }) lines: EncounterLogLine[] = [];
}

View File

@@ -0,0 +1,66 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit, computed, inject } from '@angular/core';
import { MissionDifficulty } from '@fog-explorer/api-interfaces';
import { EbsApiService } from './services/ebs-api.service';
import { MissionStateStore } from './services/mission-state.store';
import { PubSubService } from './services/pubsub.service';
import { TwitchAuthService } from './services/twitch-auth.service';
import { LiveLogComponent } from './live-log.component';
import { SurvivorStatusComponent } from './survivor-status.component';
@Component({
selector: 'fog-panel-shell',
standalone: true,
imports: [CommonModule, LiveLogComponent, SurvivorStatusComponent],
template: `
<main class="layout">
<header>
<h2>Fog Expedition</h2>
<p *ngIf="reconnecting()">reconnecting...</p>
</header>
<button (click)="startMission()">Start Mission</button>
<fog-survivor-status [mission]="mission()"></fog-survivor-status>
<fog-live-log [lines]="logLines()"></fog-live-log>
</main>
`,
styles: [
`
.layout { display: grid; gap: 0.75rem; padding: 0.75rem; }
button { background: #6d28d9; border: 0; color: white; padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; }
h2, p { margin: 0; }
`
]
})
export class PanelShellComponent implements OnInit {
private readonly auth = inject(TwitchAuthService);
private readonly api = inject(EbsApiService);
private readonly store = inject(MissionStateStore);
private readonly pubsub = inject(PubSubService);
readonly mission = this.store.mission;
readonly reconnecting = this.store.reconnecting;
readonly logLines = computed(() => this.store.logLines());
ngOnInit(): void {
this.auth.init();
}
async startMission(): Promise<void> {
const auth = this.auth.auth();
if (!auth) {
return;
}
const mission = await this.api.startMission(auth.token, {
survivorId: auth.userId,
difficulty: MissionDifficulty.Normal
});
this.store.setMission(mission);
this.pubsub.subscribeToMissionUpdates(`mission.${mission.id}`, async () => {
this.store.setReconnecting(true);
const fresh = await this.api.getMissionState(auth.token, mission.id);
this.store.setMission(fresh);
});
}
}

View File

@@ -0,0 +1,45 @@
import { Injectable } from '@angular/core';
import { MissionSnapshot } from '@fog-explorer/api-interfaces';
import { environment } from '../../environments/environment';
@Injectable({ providedIn: 'root' })
export class EbsApiService {
async startMission(token: string, body: { survivorId: string; difficulty: string }) {
return this.fetchWithRetry('/missions/start', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
}
async getMissionState(token: string, missionId: string): Promise<MissionSnapshot> {
return this.fetchWithRetry(`/missions/state?missionId=${missionId}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`
}
});
}
private async fetchWithRetry<T>(path: string, init: RequestInit, attempt = 0): Promise<T> {
try {
const response = await fetch(`${environment.apiBaseUrl}${path}`, init);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return (await response.json()) as T;
} catch (error) {
if (attempt >= 4) {
throw error;
}
const delayMs = Math.min(5000, Math.round(350 * 2 ** attempt + Math.random() * 150));
await new Promise((resolve) => setTimeout(resolve, delayMs));
return this.fetchWithRetry(path, init, attempt + 1);
}
}
}

View File

@@ -0,0 +1,19 @@
import { Injectable, computed, signal } from '@angular/core';
import { MissionSnapshot } from '@fog-explorer/api-interfaces';
@Injectable({ providedIn: 'root' })
export class MissionStateStore {
readonly mission = signal<MissionSnapshot | null>(null);
readonly reconnecting = signal(false);
readonly logLines = computed(() => this.mission()?.recentLog ?? []);
setMission(snapshot: MissionSnapshot): void {
this.mission.set(snapshot);
this.reconnecting.set(false);
}
setReconnecting(value: boolean): void {
this.reconnecting.set(value);
}
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@angular/core';
import { MissionSnapshot } from '@fog-explorer/api-interfaces';
import { MissionStateStore } from './mission-state.store';
import { TwitchAuthService } from './twitch-auth.service';
interface MissionDeltaMessage {
sequence: number;
missionId: string;
state: string;
recentLog: MissionSnapshot['recentLog'];
}
@Injectable({ providedIn: 'root' })
export class PubSubService {
private lastSequence = 0;
constructor(
private readonly auth: TwitchAuthService,
private readonly store: MissionStateStore
) {}
subscribeToMissionUpdates(topic: string, fullRefetch: () => Promise<void>): void {
this.auth.onPubSub(topic, async (_target, _contentType, message) => {
const parsed = JSON.parse(message) as MissionDeltaMessage;
if (parsed.sequence !== this.lastSequence + 1 && this.lastSequence !== 0) {
await fullRefetch();
} else {
const current = this.store.mission();
if (current && current.id === parsed.missionId) {
this.store.setMission({
...current,
state: parsed.state as MissionSnapshot['state'],
recentLog: parsed.recentLog.slice(-15),
tickIndex: parsed.sequence
});
}
}
this.lastSequence = parsed.sequence;
});
}
}

View File

@@ -0,0 +1,47 @@
import { Injectable, signal } from '@angular/core';
import { twitchExtMock, TwitchExtLike } from '../../mocks/twitch-ext.mock';
import { environment } from '../../environments/environment';
declare global {
interface Window {
Twitch?: {
ext?: TwitchExtLike;
};
}
}
export interface TwitchAuthState {
token: string;
userId: string;
}
@Injectable({ providedIn: 'root' })
export class TwitchAuthService {
readonly auth = signal<TwitchAuthState | null>(null);
init(): void {
const ext = this.getExt();
ext.onAuthorized(({ token, userId }) => {
this.auth.set({ token, userId });
});
}
onPubSub(
topic: string,
callback: (target: string, contentType: string, message: string) => void
): void {
this.getExt().listen(topic, callback);
}
private getExt(): TwitchExtLike {
if (environment.mock) {
return twitchExtMock;
}
const ext = window.Twitch?.ext;
if (!ext) {
return twitchExtMock;
}
return ext;
}
}

View File

@@ -0,0 +1,35 @@
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { MissionSnapshot } from '@fog-explorer/api-interfaces';
@Component({
selector: 'fog-survivor-status',
standalone: true,
imports: [CommonModule],
template: `
<section class="panel-card">
<h3>Survivor Status</h3>
<ng-container *ngIf="mission; else idle">
<p>Mission: {{ mission.id }}</p>
<p>State: {{ mission.state }}</p>
<p>Health: {{ mission.stats.health }}</p>
<p>Stealth: {{ mission.stats.stealth }}</p>
<p>Teamwork: {{ mission.stats.teamwork }}</p>
<p>Luck: {{ mission.stats.luck }}</p>
</ng-container>
<ng-template #idle>
<p>No active mission.</p>
</ng-template>
</section>
`,
styles: [
`
.panel-card { padding: 0.75rem; background: #1a1e2a; border-radius: 8px; }
p { margin: 0 0 0.25rem; font-size: 0.9rem; }
`
]
})
export class SurvivorStatusComponent {
@Input() mission: MissionSnapshot | null = null;
}

View File

@@ -0,0 +1,5 @@
export const environment = {
production: true,
mock: false,
apiBaseUrl: '/api'
};

View File

@@ -0,0 +1,5 @@
export const environment = {
production: false,
mock: true,
apiBaseUrl: 'http://localhost:3333'
};

View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fog Expedition Panel</title>
</head>
<body>
<fog-panel-shell></fog-panel-shell>
</body>
</html>

View File

@@ -0,0 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { PanelShellComponent } from './app/panel-shell.component';
bootstrapApplication(PanelShellComponent).catch((error) => {
console.error(error);
});

View File

@@ -0,0 +1,25 @@
export interface TwitchExtLike {
onAuthorized(callback: (auth: { token: string; userId: string }) => void): void;
onContext(callback: (context: Record<string, unknown>) => void): void;
onVisibilityChanged(callback: (isVisible: boolean, context: unknown) => void): void;
listen(topic: string, callback: (target: string, contentType: string, message: string) => void): void;
}
export const twitchExtMock: TwitchExtLike = {
onAuthorized(callback) {
callback({
token:
'mock.jwt.token',
userId: 'U_mock_viewer'
});
},
onContext(callback) {
callback({ theme: 'dark' });
},
onVisibilityChanged(callback) {
callback(true, {});
},
listen(_topic, _callback) {
// No-op in local mock mode.
}
};

View File

@@ -0,0 +1,10 @@
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
}
body {
margin: 0;
background: #0f1117;
color: #f3f4f6;
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"]
}