Compare commits
2 Commits
e8523d270e
...
0031ef0a8f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0031ef0a8f | ||
|
|
21f1a5319f |
@@ -2,6 +2,9 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(node -e *)",
|
"Bash(node -e *)",
|
||||||
|
"Bash(python3 *)",
|
||||||
|
"Bash(curl *)",
|
||||||
|
"Bash(node *)",
|
||||||
"Bash(node --input-type=module)",
|
"Bash(node --input-type=module)",
|
||||||
"Bash(2>&1)",
|
"Bash(2>&1)",
|
||||||
"Bash(pnpm *)"
|
"Bash(pnpm *)"
|
||||||
|
|||||||
5
.env
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
DATABASE_URL="postgresql://fog:fog_dev@172.20.0.3:5432/fog_expedition"
|
||||||
|
REDIS_HOST="172.20.0.2"
|
||||||
|
REDIS_PORT="6379"
|
||||||
|
TWITCH_EXTENSION_SECRET="dev_secret_placeholder"
|
||||||
|
DEV_AUTH_BYPASS="true"
|
||||||
@@ -38,11 +38,14 @@ This is a Twitch Video Overlay extension implementing an autonomous tick-based Z
|
|||||||
|
|
||||||
## Hard rules
|
## Hard rules
|
||||||
|
|
||||||
|
- Don’t assume. Don’t hide confusion. Surface tradeoffs.
|
||||||
|
- Minimum code that solves the problem. Nothing speculative.
|
||||||
|
- Touch only what you must. Clean up only your own mess.
|
||||||
|
- Define success criteria. Loop until verified.
|
||||||
- Do not use `Math.random()` anywhere in game logic.
|
- Do not use `Math.random()` anywhere in game logic.
|
||||||
- Do not skip `nextTickAt` jitter — synchronised global ticks will thunder-herd Postgres.
|
- Do not skip `nextTickAt` jitter — synchronised global ticks will thunder-herd Postgres.
|
||||||
- Do not assume Twitch PubSub message delivery; treat as a hint to refresh, reconcile from REST.
|
- Do not assume Twitch PubSub message delivery; treat as a hint to refresh, reconcile from REST.
|
||||||
- Do not add inline scripts or inline event handlers to the overlay HTML — Twitch CSP forbids them.
|
- Do not add inline scripts or inline event handlers to the overlay HTML — Twitch CSP forbids them.
|
||||||
- Do not reproduce Dead by Daylight assets, character names, or trademarked terms in encounter content. The aesthetic is *inspired by* the genre, not a clone.
|
|
||||||
|
|
||||||
## Twitch extension specifics
|
## Twitch extension specifics
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"twitch_opaque_user_id" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "survivors" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"channel_id" TEXT NOT NULL,
|
||||||
|
"name" VARCHAR(32) NOT NULL,
|
||||||
|
"state" TEXT NOT NULL DEFAULT 'active',
|
||||||
|
"stats" JSONB NOT NULL,
|
||||||
|
"perk_slots" JSONB NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "survivors_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "missions" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"group_id" UUID,
|
||||||
|
"channel_id" TEXT NOT NULL,
|
||||||
|
"difficulty" SMALLINT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'active',
|
||||||
|
"encounter_library_version" TEXT NOT NULL,
|
||||||
|
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"ended_at" TIMESTAMP(3),
|
||||||
|
"tick_index" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"next_tick_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "missions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "mission_participants" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"mission_id" UUID NOT NULL,
|
||||||
|
"survivor_id" UUID NOT NULL,
|
||||||
|
"state" TEXT NOT NULL DEFAULT 'active',
|
||||||
|
"hook_count" SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "mission_participants_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "mission_logs" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"mission_id" UUID NOT NULL,
|
||||||
|
"tick_index" INTEGER NOT NULL,
|
||||||
|
"encounter_key" TEXT NOT NULL,
|
||||||
|
"rendered_text" TEXT NOT NULL,
|
||||||
|
"seed" TEXT NOT NULL,
|
||||||
|
"modifiers_applied" JSONB NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "mission_logs_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "users_twitch_opaque_user_id_key" ON "users"("twitch_opaque_user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "missions_channel_id_idx" ON "missions"("channel_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "missions_status_next_tick_at_idx" ON "missions"("status", "next_tick_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "mission_participants_mission_id_survivor_id_key" ON "mission_participants"("mission_id", "survivor_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "mission_logs_mission_id_tick_index_idx" ON "mission_logs"("mission_id", "tick_index");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "survivors" ADD CONSTRAINT "survivors_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "mission_participants" ADD CONSTRAINT "mission_participants_mission_id_fkey" FOREIGN KEY ("mission_id") REFERENCES "missions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "mission_participants" ADD CONSTRAINT "mission_participants_survivor_id_fkey" FOREIGN KEY ("survivor_id") REFERENCES "survivors"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "mission_logs" ADD CONSTRAINT "mission_logs_mission_id_fkey" FOREIGN KEY ("mission_id") REFERENCES "missions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "missions" ADD COLUMN "duration_minutes" SMALLINT NOT NULL DEFAULT 20;
|
||||||
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
||||||
81
apps/api/prisma/schema.prisma
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
output = "../../../node_modules/.prisma/client"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
opaqueUserId String @unique @map("twitch_opaque_user_id")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
survivors Survivor[]
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Survivor {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
channelId String @map("channel_id")
|
||||||
|
name String @db.VarChar(32)
|
||||||
|
state String @default("active")
|
||||||
|
stats Json
|
||||||
|
perkSlots Json @map("perk_slots")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
participants MissionParticipant[]
|
||||||
|
|
||||||
|
@@map("survivors")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Mission {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
groupId String? @map("group_id") @db.Uuid
|
||||||
|
channelId String @map("channel_id")
|
||||||
|
difficulty Int @db.SmallInt
|
||||||
|
durationMinutes Int @default(20) @map("duration_minutes") @db.SmallInt
|
||||||
|
status String @default("active")
|
||||||
|
encounterLibraryVersion String @map("encounter_library_version")
|
||||||
|
startedAt DateTime @default(now()) @map("started_at")
|
||||||
|
endedAt DateTime? @map("ended_at")
|
||||||
|
tickIndex Int @default(0) @map("tick_index")
|
||||||
|
nextTickAt DateTime @map("next_tick_at")
|
||||||
|
participants MissionParticipant[]
|
||||||
|
logs MissionLog[]
|
||||||
|
|
||||||
|
@@index([channelId])
|
||||||
|
@@index([status, nextTickAt])
|
||||||
|
@@map("missions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model MissionParticipant {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
missionId String @map("mission_id") @db.Uuid
|
||||||
|
mission Mission @relation(fields: [missionId], references: [id])
|
||||||
|
survivorId String @map("survivor_id") @db.Uuid
|
||||||
|
survivor Survivor @relation(fields: [survivorId], references: [id])
|
||||||
|
state String @default("active")
|
||||||
|
hookCount Int @default(0) @map("hook_count") @db.SmallInt
|
||||||
|
|
||||||
|
@@unique([missionId, survivorId])
|
||||||
|
@@map("mission_participants")
|
||||||
|
}
|
||||||
|
|
||||||
|
model MissionLog {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
missionId String @map("mission_id") @db.Uuid
|
||||||
|
mission Mission @relation(fields: [missionId], references: [id])
|
||||||
|
tickIndex Int @map("tick_index")
|
||||||
|
encounterKey String @map("encounter_key")
|
||||||
|
renderedText String @map("rendered_text")
|
||||||
|
seed String
|
||||||
|
modifiersApplied Json @map("modifiers_applied")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([missionId, tickIndex])
|
||||||
|
@@map("mission_logs")
|
||||||
|
}
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { AppLoggerModule } from './logger/logger.module';
|
||||||
import { MissionsModule } from './missions/missions.module';
|
import { MissionsModule } from './missions/missions.module';
|
||||||
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { TickEngineModule } from './tick-engine/tick-engine.module';
|
import { TickEngineModule } from './tick-engine/tick-engine.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
AppLoggerModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
|
ThrottlerModule.forRoot({
|
||||||
|
throttlers: [{ ttl: 1000, limit: 300 }],
|
||||||
|
}),
|
||||||
|
PrismaModule,
|
||||||
MissionsModule,
|
MissionsModule,
|
||||||
TickEngineModule,
|
TickEngineModule,
|
||||||
],
|
],
|
||||||
|
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { createHmac } from 'crypto';
|
import { createHmac } from 'crypto';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
export interface TwitchJwtPayload {
|
export interface TwitchJwtPayload {
|
||||||
opaque_user_id: string;
|
opaque_user_id: string;
|
||||||
@@ -17,6 +18,7 @@ export interface TwitchJwtPayload {
|
|||||||
interface HttpRequest {
|
interface HttpRequest {
|
||||||
headers: Record<string, string | string[] | undefined>;
|
headers: Record<string, string | string[] | undefined>;
|
||||||
twitchClaims?: TwitchJwtPayload;
|
twitchClaims?: TwitchJwtPayload;
|
||||||
|
log?: { bindings: () => Record<string, unknown>; child: (b: Record<string, unknown>) => unknown };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TwitchClaims = createParamDecorator(
|
export const TwitchClaims = createParamDecorator(
|
||||||
@@ -29,6 +31,10 @@ export const TwitchClaims = createParamDecorator(
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TwitchJwtGuard implements CanActivate {
|
export class TwitchJwtGuard implements CanActivate {
|
||||||
|
constructor(private readonly logger: PinoLogger) {
|
||||||
|
this.logger.setContext(TwitchJwtGuard.name);
|
||||||
|
}
|
||||||
|
|
||||||
canActivate(ctx: ExecutionContext): boolean {
|
canActivate(ctx: ExecutionContext): boolean {
|
||||||
const req = ctx.switchToHttp().getRequest<HttpRequest>();
|
const req = ctx.switchToHttp().getRequest<HttpRequest>();
|
||||||
const authHeader = req.headers['authorization'];
|
const authHeader = req.headers['authorization'];
|
||||||
@@ -36,9 +42,50 @@ export class TwitchJwtGuard implements CanActivate {
|
|||||||
if (!auth?.startsWith('Bearer ')) throw new UnauthorizedException();
|
if (!auth?.startsWith('Bearer ')) throw new UnauthorizedException();
|
||||||
|
|
||||||
const token = auth.slice(7);
|
const token = auth.slice(7);
|
||||||
req.twitchClaims = verifyAndDecode(token);
|
const devClaims = tryDevBypass(token);
|
||||||
|
if (devClaims) {
|
||||||
|
req.twitchClaims = devClaims;
|
||||||
|
this.logger.warn({ msg: 'dev auth bypass active', opaqueUserId: devClaims.opaque_user_id });
|
||||||
|
this.logger.assign({ channelId: devClaims.channel_id, opaqueUserId: devClaims.opaque_user_id });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.twitchClaims = verifyAndDecode(token);
|
||||||
|
|
||||||
|
// Bind Twitch identity into this request's pino logger so every subsequent
|
||||||
|
// log line in the request lifecycle carries channelId and opaqueUserId.
|
||||||
|
this.logger.assign({
|
||||||
|
channelId: req.twitchClaims.channel_id,
|
||||||
|
opaqueUserId: req.twitchClaims.opaque_user_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts the fake dev JWT minted by TwitchAuthService.buildDevAuth() when
|
||||||
|
* NODE_ENV=development. Tokens are identified by their `.dev` signature and
|
||||||
|
* a UDEV-prefixed opaque_user_id. Fails loudly if called outside dev mode —
|
||||||
|
* this guard must NEVER run in production.
|
||||||
|
*/
|
||||||
|
function tryDevBypass(token: string): TwitchJwtPayload | null {
|
||||||
|
if (process.env['NODE_ENV'] !== 'development') return null;
|
||||||
|
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3 || parts[2] !== 'dev') return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = Buffer.from(
|
||||||
|
parts[1].replace(/-/g, '+').replace(/_/g, '/'),
|
||||||
|
'base64'
|
||||||
|
).toString('utf8');
|
||||||
|
const payload = JSON.parse(raw) as TwitchJwtPayload;
|
||||||
|
if (!payload.opaque_user_id?.startsWith('UDEV')) return null;
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function verifyAndDecode(token: string): TwitchJwtPayload {
|
function verifyAndDecode(token: string): TwitchJwtPayload {
|
||||||
|
|||||||
27
apps/api/src/app/logger/logger.module.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
|
|
||||||
|
const isDev = process.env['NODE_ENV'] !== 'production';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
LoggerModule.forRoot({
|
||||||
|
pinoHttp: {
|
||||||
|
level: process.env['LOG_LEVEL'] ?? (isDev ? 'debug' : 'info'),
|
||||||
|
// pino-pretty in dev; raw JSON in prod (consumed by log aggregators).
|
||||||
|
transport: isDev
|
||||||
|
? { target: 'pino-pretty', options: { colorize: true, singleLine: false } }
|
||||||
|
: undefined,
|
||||||
|
// Suppress /health probes from cluttering the log stream.
|
||||||
|
autoLogging: {
|
||||||
|
ignore: (req) => req.url?.includes('/health') ?? false,
|
||||||
|
},
|
||||||
|
serializers: {
|
||||||
|
req: (req) => ({ method: req.method, url: req.url }),
|
||||||
|
res: (res) => ({ statusCode: res.statusCode }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppLoggerModule {}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
import type {
|
import type {
|
||||||
EncounterResult,
|
EncounterResult,
|
||||||
Mission,
|
Mission,
|
||||||
@@ -21,16 +22,18 @@ const RECENT_LOG_MAX = 20;
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EncounterService {
|
export class EncounterService {
|
||||||
private readonly logger = new Logger(EncounterService.name);
|
constructor(
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
constructor(private readonly groupSynergy: GroupSynergyService) {}
|
private readonly groupSynergy: GroupSynergyService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves one tick for the given mission state.
|
* Resolves one tick for the given mission state.
|
||||||
* Returns the updated state, or null if the mission ended.
|
* channelId is threaded in from the TickService for structured log correlation.
|
||||||
*/
|
*/
|
||||||
processTick(
|
processTick(
|
||||||
current: NonNullable<MissionStateResponse>
|
current: NonNullable<MissionStateResponse>,
|
||||||
|
channelId: string | null,
|
||||||
): NonNullable<MissionStateResponse> {
|
): NonNullable<MissionStateResponse> {
|
||||||
const { mission, survivors } = current;
|
const { mission, survivors } = current;
|
||||||
const tickIndex = mission.tickIndex + 1;
|
const tickIndex = mission.tickIndex + 1;
|
||||||
@@ -56,7 +59,7 @@ export class EncounterService {
|
|||||||
|
|
||||||
const augmentedPerks = this.groupSynergy.buildAugmentedPerks(
|
const augmentedPerks = this.groupSynergy.buildAugmentedPerks(
|
||||||
survivor,
|
survivor,
|
||||||
groupModifiers
|
groupModifiers,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = resolveEncounter({
|
const result = resolveEncounter({
|
||||||
@@ -81,21 +84,22 @@ export class EncounterService {
|
|||||||
|
|
||||||
newLog.push({ ...result, logText: flavor });
|
newLog.push({ ...result, logText: flavor });
|
||||||
|
|
||||||
this.logger.log({
|
this.logger.info({
|
||||||
message: 'tick resolved',
|
msg: 'tick resolved',
|
||||||
missionId: mission.id,
|
missionId: mission.id,
|
||||||
channelId: 'unknown',
|
channelId,
|
||||||
tickIndex,
|
tickIndex,
|
||||||
survivorId: survivor.id,
|
survivorId: survivor.id,
|
||||||
encounterKey: encounter.key,
|
encounterKey: encounter.key,
|
||||||
success: result.success,
|
success: result.success,
|
||||||
seed: result.seed,
|
seed: result.seed,
|
||||||
|
modifiersApplied: result.modifiersApplied.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
const stateChange = result.survivorStateChange;
|
const stateChange = result.survivorStateChange;
|
||||||
if (stateChange) {
|
if (stateChange) {
|
||||||
updatedSurvivors = updatedSurvivors.map((s) =>
|
updatedSurvivors = updatedSurvivors.map((s) =>
|
||||||
s.id === survivor.id ? { ...s, state: stateChange.to } : s
|
s.id === survivor.id ? { ...s, state: stateChange.to } : s,
|
||||||
);
|
);
|
||||||
updatedParticipants = updatedParticipants.map((p) => {
|
updatedParticipants = updatedParticipants.map((p) => {
|
||||||
if (p.survivorId !== survivor.id) return p;
|
if (p.survivorId !== survivor.id) return p;
|
||||||
@@ -111,13 +115,13 @@ export class EncounterService {
|
|||||||
const updatedMission = buildUpdatedMission(
|
const updatedMission = buildUpdatedMission(
|
||||||
mission,
|
mission,
|
||||||
updatedParticipants,
|
updatedParticipants,
|
||||||
tickIndex
|
tickIndex,
|
||||||
);
|
);
|
||||||
|
|
||||||
const recentLog = [
|
const recentLog = [...newLog, ...current.recentLog].slice(
|
||||||
...newLog,
|
0,
|
||||||
...current.recentLog,
|
RECENT_LOG_MAX,
|
||||||
].slice(0, RECENT_LOG_MAX);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mission: updatedMission,
|
mission: updatedMission,
|
||||||
@@ -134,14 +138,9 @@ function buildSeed(missionId: string, tickIndex: number): string {
|
|||||||
function buildUpdatedMission(
|
function buildUpdatedMission(
|
||||||
mission: Mission,
|
mission: Mission,
|
||||||
participants: MissionParticipant[],
|
participants: MissionParticipant[],
|
||||||
tickIndex: number
|
tickIndex: number,
|
||||||
): Mission {
|
): Mission {
|
||||||
const allSacrificed = participants.every(
|
const allSacrificed = participants.every((p) => p.state === 'sacrificed');
|
||||||
(p) => p.state === 'sacrificed'
|
|
||||||
);
|
|
||||||
const allEscaped = participants.every(
|
|
||||||
(p) => p.state === 'active' || p.state === 'idle'
|
|
||||||
);
|
|
||||||
|
|
||||||
let status = mission.status;
|
let status = mission.status;
|
||||||
let endedAt = mission.endedAt;
|
let endedAt = mission.endedAt;
|
||||||
@@ -149,15 +148,14 @@ function buildUpdatedMission(
|
|||||||
if (allSacrificed && mission.status === 'active') {
|
if (allSacrificed && mission.status === 'active') {
|
||||||
status = 'sacrifice';
|
status = 'sacrifice';
|
||||||
endedAt = new Date().toISOString();
|
endedAt = new Date().toISOString();
|
||||||
} else if (allEscaped && tickIndex >= 10 && mission.status === 'active') {
|
} else if (!allSacrificed && tickIndex >= mission.durationMinutes && mission.status === 'active') {
|
||||||
// Success after at least 10 ticks with all survivors still active
|
|
||||||
status = 'success';
|
status = 'success';
|
||||||
endedAt = new Date().toISOString();
|
endedAt = new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const jitter = Math.floor(Math.random() * TICK_JITTER_MS);
|
const jitter = Math.floor(Math.random() * TICK_JITTER_MS);
|
||||||
const nextTickAt = new Date(
|
const nextTickAt = new Date(
|
||||||
Date.now() + TICK_BASE_INTERVAL_MS + jitter
|
Date.now() + TICK_BASE_INTERVAL_MS + jitter,
|
||||||
).toISOString();
|
).toISOString();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
NotFoundException,
|
|
||||||
Post,
|
Post,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import {
|
import {
|
||||||
MissionStateResponse,
|
MissionStateResponse,
|
||||||
MissionStateResponseSchema,
|
MissionStateResponseSchema,
|
||||||
StartMissionRequestSchema,
|
StartMissionRequestSchema,
|
||||||
} from '@fog-explorer/api-interfaces';
|
} from '@fog-explorer/api-interfaces';
|
||||||
import { TwitchClaims, TwitchJwtGuard, TwitchJwtPayload } from '../auth/twitch-jwt.guard';
|
import {
|
||||||
|
TwitchClaims,
|
||||||
|
TwitchJwtGuard,
|
||||||
|
TwitchJwtPayload,
|
||||||
|
} from '../auth/twitch-jwt.guard';
|
||||||
import { MissionStoreService } from './mission-store.service';
|
import { MissionStoreService } from './mission-store.service';
|
||||||
import { MissionsService } from './missions.service';
|
import { MissionsService } from './missions.service';
|
||||||
|
|
||||||
@@ -22,27 +27,39 @@ import { MissionsService } from './missions.service';
|
|||||||
export class MissionsController {
|
export class MissionsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly store: MissionStoreService,
|
private readonly store: MissionStoreService,
|
||||||
private readonly missions: MissionsService
|
private readonly missions: MissionsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('state')
|
@Get('state')
|
||||||
|
@Throttle({ default: { limit: 100, ttl: 1000 } })
|
||||||
async getState(
|
async getState(
|
||||||
@TwitchClaims() claims: TwitchJwtPayload
|
@TwitchClaims() claims: TwitchJwtPayload,
|
||||||
): Promise<MissionStateResponse> {
|
): Promise<MissionStateResponse> {
|
||||||
const state = await this.store.getStateForChannel(claims.channel_id);
|
const state = await this.store.getStateForChannel(claims.channel_id);
|
||||||
return MissionStateResponseSchema.parse(state);
|
return MissionStateResponseSchema.parse(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('abandon')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async abandonMission(
|
||||||
|
@TwitchClaims() claims: TwitchJwtPayload,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!claims.opaque_user_id.startsWith('U')) {
|
||||||
|
throw new ForbiddenException('Anonymous viewers cannot abandon missions');
|
||||||
|
}
|
||||||
|
await this.missions.abandonMission(claims);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('start')
|
@Post('start')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
async startMission(
|
async startMission(
|
||||||
@TwitchClaims() claims: TwitchJwtPayload,
|
@TwitchClaims() claims: TwitchJwtPayload,
|
||||||
@Body() body: unknown
|
@Body() body: unknown,
|
||||||
): Promise<MissionStateResponse> {
|
): Promise<NonNullable<MissionStateResponse>> {
|
||||||
if (!claims.opaque_user_id.startsWith('U')) {
|
if (!claims.opaque_user_id.startsWith('U')) {
|
||||||
throw new NotFoundException('Anonymous viewers cannot start missions');
|
throw new ForbiddenException('Anonymous viewers cannot start missions');
|
||||||
}
|
}
|
||||||
const { difficulty } = StartMissionRequestSchema.parse(body);
|
const { difficulty, durationMinutes, characterName } = StartMissionRequestSchema.parse(body);
|
||||||
return this.missions.startMission(claims, difficulty);
|
return this.missions.startMission(claims, difficulty, durationMinutes, characterName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
|
import { TwitchJwtGuard } from '../auth/twitch-jwt.guard';
|
||||||
import { RedisModule } from '../redis/redis.module';
|
import { RedisModule } from '../redis/redis.module';
|
||||||
import { EncounterService } from './encounter.service';
|
import { EncounterService } from './encounter.service';
|
||||||
import { GroupSynergyService } from './group-synergy.service';
|
import { GroupSynergyService } from './group-synergy.service';
|
||||||
@@ -7,14 +9,15 @@ import { MissionsController } from './missions.controller';
|
|||||||
import { MissionsService } from './missions.service';
|
import { MissionsService } from './missions.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [RedisModule],
|
imports: [RedisModule, LoggerModule],
|
||||||
controllers: [MissionsController],
|
controllers: [MissionsController],
|
||||||
providers: [
|
providers: [
|
||||||
|
TwitchJwtGuard,
|
||||||
MissionStoreService,
|
MissionStoreService,
|
||||||
MissionsService,
|
MissionsService,
|
||||||
EncounterService,
|
EncounterService,
|
||||||
GroupSynergyService,
|
GroupSynergyService,
|
||||||
],
|
],
|
||||||
exports: [MissionStoreService, EncounterService],
|
exports: [MissionStoreService, EncounterService, GroupSynergyService],
|
||||||
})
|
})
|
||||||
export class MissionsModule {}
|
export class MissionsModule {}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import type {
|
import type {
|
||||||
Mission,
|
Mission,
|
||||||
|
MissionDurationMinutes,
|
||||||
MissionStateResponse,
|
MissionStateResponse,
|
||||||
Survivor,
|
Survivor,
|
||||||
SurvivorStats,
|
SurvivorStats,
|
||||||
} from '@fog-explorer/api-interfaces';
|
} from '@fog-explorer/api-interfaces';
|
||||||
import { getLibraryVersion } from '@fog-explorer/encounter-library';
|
import { getLibraryVersion } from '@fog-explorer/encounter-library';
|
||||||
import { TwitchJwtPayload } from '../auth/twitch-jwt.guard';
|
import { TwitchJwtPayload } from '../auth/twitch-jwt.guard';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { MissionStoreService } from './mission-store.service';
|
import { MissionStoreService } from './mission-store.service';
|
||||||
|
|
||||||
const TICK_BASE_INTERVAL_MS = 60_000;
|
const TICK_BASE_INTERVAL_MS = 60_000;
|
||||||
@@ -14,29 +20,75 @@ const TICK_JITTER_MS = 5_000;
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MissionsService {
|
export class MissionsService {
|
||||||
constructor(private readonly store: MissionStoreService) {}
|
constructor(
|
||||||
|
private readonly store: MissionStoreService,
|
||||||
|
private readonly prisma: PrismaService
|
||||||
|
) {}
|
||||||
|
|
||||||
async startMission(
|
async startMission(
|
||||||
claims: TwitchJwtPayload,
|
claims: TwitchJwtPayload,
|
||||||
difficulty: number
|
difficulty: number,
|
||||||
|
durationMinutes: MissionDurationMinutes,
|
||||||
|
characterName?: string,
|
||||||
): Promise<NonNullable<MissionStateResponse>> {
|
): Promise<NonNullable<MissionStateResponse>> {
|
||||||
const missionId = crypto.randomUUID();
|
const missionId = crypto.randomUUID();
|
||||||
const survivorId = crypto.randomUUID();
|
const survivorId = crypto.randomUUID();
|
||||||
const now = new Date().toISOString();
|
const now = new Date();
|
||||||
const jitter = Math.floor(Math.random() * TICK_JITTER_MS);
|
const jitter = Math.floor(Math.random() * TICK_JITTER_MS);
|
||||||
const nextTickAt = new Date(Date.now() + TICK_BASE_INTERVAL_MS + jitter).toISOString();
|
const nextTickAt = new Date(now.getTime() + TICK_BASE_INTERVAL_MS + jitter);
|
||||||
|
const stats: SurvivorStats = { objectives: 5, survival: 5, altruism: 5 };
|
||||||
|
const survivorName = characterName ?? defaultName(claims.opaque_user_id);
|
||||||
|
|
||||||
const stats: SurvivorStats = defaultStats();
|
// Upsert user and create survivor + mission in one transaction.
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const user = await tx.user.upsert({
|
||||||
|
where: { opaqueUserId: claims.opaque_user_id },
|
||||||
|
create: { id: crypto.randomUUID(), opaqueUserId: claims.opaque_user_id },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.survivor.create({
|
||||||
|
data: {
|
||||||
|
id: survivorId,
|
||||||
|
userId: user.id,
|
||||||
|
channelId: claims.channel_id,
|
||||||
|
name: survivorName,
|
||||||
|
state: 'active',
|
||||||
|
stats,
|
||||||
|
perkSlots: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.mission.create({
|
||||||
|
data: {
|
||||||
|
id: missionId,
|
||||||
|
channelId: claims.channel_id,
|
||||||
|
difficulty,
|
||||||
|
durationMinutes,
|
||||||
|
status: 'active',
|
||||||
|
encounterLibraryVersion: getLibraryVersion(),
|
||||||
|
nextTickAt,
|
||||||
|
participants: {
|
||||||
|
create: {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
survivorId,
|
||||||
|
state: 'active',
|
||||||
|
hookCount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const survivor: Survivor = {
|
const survivor: Survivor = {
|
||||||
id: survivorId,
|
id: survivorId,
|
||||||
opaqueUserId: claims.opaque_user_id,
|
opaqueUserId: claims.opaque_user_id,
|
||||||
channelId: claims.channel_id,
|
channelId: claims.channel_id,
|
||||||
name: defaultName(claims.opaque_user_id),
|
name: survivorName,
|
||||||
state: 'active',
|
state: 'active',
|
||||||
stats,
|
stats,
|
||||||
perkSlots: [],
|
perkSlots: [],
|
||||||
createdAt: now,
|
createdAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mission: Mission = {
|
const mission: Mission = {
|
||||||
@@ -44,11 +96,12 @@ export class MissionsService {
|
|||||||
groupId: null,
|
groupId: null,
|
||||||
participants: [{ survivorId, state: 'active', hookCount: 0 }],
|
participants: [{ survivorId, state: 'active', hookCount: 0 }],
|
||||||
difficulty,
|
difficulty,
|
||||||
|
durationMinutes,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
encounterLibraryVersion: getLibraryVersion(),
|
encounterLibraryVersion: getLibraryVersion(),
|
||||||
nextTickAt,
|
nextTickAt: nextTickAt.toISOString(),
|
||||||
tickIndex: 0,
|
tickIndex: 0,
|
||||||
startedAt: now,
|
startedAt: now.toISOString(),
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,14 +113,42 @@ export class MissionsService {
|
|||||||
|
|
||||||
await this.store.setActiveMission(state);
|
await this.store.setActiveMission(state);
|
||||||
await this.store.setChannelMissionId(claims.channel_id, missionId);
|
await this.store.setChannelMissionId(claims.channel_id, missionId);
|
||||||
await this.store.scheduleTick(missionId, new Date(nextTickAt).getTime());
|
await this.store.scheduleTick(missionId, nextTickAt.getTime());
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function defaultStats(): SurvivorStats {
|
async abandonMission(claims: TwitchJwtPayload): Promise<void> {
|
||||||
return { objectives: 5, survival: 5, altruism: 5 };
|
const state = await this.store.getStateForChannel(claims.channel_id);
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
throw new NotFoundException('No active mission');
|
||||||
|
}
|
||||||
|
if (state.mission.status !== 'active') {
|
||||||
|
throw new ForbiddenException('Mission is not active');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isParticipant = state.survivors.some(
|
||||||
|
(s) => s.opaqueUserId === claims.opaque_user_id,
|
||||||
|
);
|
||||||
|
if (!isParticipant) {
|
||||||
|
throw new ForbiddenException('Not a participant in this mission');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const updated: NonNullable<MissionStateResponse> = {
|
||||||
|
...state,
|
||||||
|
mission: { ...state.mission, status: 'abandoned', endedAt: now },
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.prisma.mission.update({
|
||||||
|
where: { id: state.mission.id },
|
||||||
|
data: { status: 'abandoned', endedAt: new Date(now) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.store.setActiveMission(updated);
|
||||||
|
await this.store.removeMissionFromQueue(state.mission.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultName(opaqueUserId: string): string {
|
function defaultName(opaqueUserId: string): string {
|
||||||
|
|||||||
9
apps/api/src/app/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
13
apps/api/src/app/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
import { MissionsModule } from '../missions/missions.module';
|
import { MissionsModule } from '../missions/missions.module';
|
||||||
import { TickService } from './tick.service';
|
import { TickService } from './tick.service';
|
||||||
|
import { TwitchPubSubService } from './twitch-pubsub.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MissionsModule],
|
imports: [MissionsModule, LoggerModule],
|
||||||
providers: [TickService],
|
providers: [TickService, TwitchPubSubService],
|
||||||
})
|
})
|
||||||
export class TickEngineModule {}
|
export class TickEngineModule {}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { MissionStoreService } from '../missions/mission-store.service';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { EncounterService } from '../missions/encounter.service';
|
import { EncounterService } from '../missions/encounter.service';
|
||||||
|
import { MissionStoreService } from '../missions/mission-store.service';
|
||||||
|
import { TwitchPubSubService } from './twitch-pubsub.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TickService {
|
export class TickService {
|
||||||
private readonly logger = new Logger(TickService.name);
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private readonly logger: PinoLogger,
|
||||||
private readonly store: MissionStoreService,
|
private readonly store: MissionStoreService,
|
||||||
private readonly encounters: EncounterService
|
private readonly encounters: EncounterService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly pubsub: TwitchPubSubService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_10_SECONDS)
|
@Cron(CronExpression.EVERY_10_SECONDS)
|
||||||
@@ -18,13 +22,17 @@ export class TickService {
|
|||||||
const dueMissionIds = await this.store.getDueMissionIds(now);
|
const dueMissionIds = await this.store.getDueMissionIds(now);
|
||||||
|
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
dueMissionIds.map((id) => this.processMission(id))
|
dueMissionIds.map((id) => this.processMission(id)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processMission(missionId: string): Promise<void> {
|
private async processMission(missionId: string): Promise<void> {
|
||||||
const token = await this.store.acquireLock(missionId);
|
const token = await this.store.acquireLock(missionId);
|
||||||
if (!token) return; // Another worker has the lock
|
if (!token) return;
|
||||||
|
|
||||||
|
// Fetch channelId up-front so every log line in this mission carries it.
|
||||||
|
const channelId = await this.getChannelId(missionId);
|
||||||
|
const log = this.logger.logger.child({ missionId, channelId });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const state = await this.store.getActiveMission(missionId);
|
const state = await this.store.getActiveMission(missionId);
|
||||||
@@ -33,29 +41,87 @@ export class TickService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = this.encounters.processTick(state);
|
const updated = this.encounters.processTick(state, channelId);
|
||||||
|
const newLogs = updated.recentLog.slice(
|
||||||
|
0,
|
||||||
|
updated.recentLog.length - state.recentLog.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Write to Postgres in a transaction.
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.mission.update({
|
||||||
|
where: { id: missionId },
|
||||||
|
data: {
|
||||||
|
tickIndex: updated.mission.tickIndex,
|
||||||
|
nextTickAt: new Date(updated.mission.nextTickAt),
|
||||||
|
status: updated.mission.status,
|
||||||
|
endedAt: updated.mission.endedAt
|
||||||
|
? new Date(updated.mission.endedAt)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const survivor of updated.survivors) {
|
||||||
|
const participant = updated.mission.participants.find(
|
||||||
|
(p) => p.survivorId === survivor.id,
|
||||||
|
);
|
||||||
|
if (!participant) continue;
|
||||||
|
await tx.missionParticipant.updateMany({
|
||||||
|
where: { missionId, survivorId: survivor.id },
|
||||||
|
data: {
|
||||||
|
state: participant.state,
|
||||||
|
hookCount: participant.hookCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.survivor.update({
|
||||||
|
where: { id: survivor.id },
|
||||||
|
data: { state: survivor.state },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newLogs.length > 0) {
|
||||||
|
await tx.missionLog.createMany({
|
||||||
|
data: newLogs.map((entry) => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
missionId,
|
||||||
|
tickIndex: entry.tickIndex,
|
||||||
|
encounterKey: entry.encounterKey,
|
||||||
|
renderedText: entry.logText,
|
||||||
|
seed: entry.seed,
|
||||||
|
modifiersApplied: entry.modifiersApplied,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Redis cache.
|
||||||
await this.store.setActiveMission(updated);
|
await this.store.setActiveMission(updated);
|
||||||
|
|
||||||
if (
|
if (updated.mission.status === 'active') {
|
||||||
updated.mission.status === 'active' ||
|
|
||||||
updated.mission.status === 'lobby'
|
|
||||||
) {
|
|
||||||
const nextMs = new Date(updated.mission.nextTickAt).getTime();
|
const nextMs = new Date(updated.mission.nextTickAt).getTime();
|
||||||
await this.store.scheduleTick(missionId, nextMs);
|
await this.store.scheduleTick(missionId, nextMs);
|
||||||
} else {
|
} else {
|
||||||
await this.store.removeMissionFromQueue(missionId);
|
await this.store.removeMissionFromQueue(missionId);
|
||||||
this.logger.log({
|
log.info({
|
||||||
message: 'mission ended',
|
msg: 'mission ended',
|
||||||
missionId,
|
|
||||||
status: updated.mission.status,
|
status: updated.mission.status,
|
||||||
tickIndex: updated.mission.tickIndex,
|
tickIndex: updated.mission.tickIndex,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.pubsub.broadcast(channelId, updated);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error({ message: 'tick failed', missionId, err });
|
log.error({ msg: 'tick failed', err });
|
||||||
} finally {
|
} finally {
|
||||||
await this.store.releaseLock(missionId, token);
|
await this.store.releaseLock(missionId, token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getChannelId(missionId: string): Promise<string | null> {
|
||||||
|
const mission = await this.prisma.mission.findUnique({
|
||||||
|
where: { id: missionId },
|
||||||
|
select: { channelId: true },
|
||||||
|
});
|
||||||
|
return mission?.channelId ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
apps/api/src/app/tick-engine/twitch-pubsub.service.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { createHmac } from 'crypto';
|
||||||
|
import type { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
||||||
|
|
||||||
|
const PUBSUB_URL = 'https://api.twitch.tv/extensions/message';
|
||||||
|
const TOKEN_TTL_SECONDS = 60;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TwitchPubSubService {
|
||||||
|
private readonly logger = new Logger(TwitchPubSubService.name);
|
||||||
|
|
||||||
|
async broadcast(
|
||||||
|
channelId: string | null,
|
||||||
|
state: NonNullable<MissionStateResponse>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!channelId) return;
|
||||||
|
|
||||||
|
const clientId = process.env['TWITCH_CLIENT_ID'];
|
||||||
|
const secret = process.env['TWITCH_EXTENSION_SECRET'];
|
||||||
|
if (!clientId || !secret) return;
|
||||||
|
|
||||||
|
const token = buildServerToken(clientId, secret);
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
content_type: 'application/json',
|
||||||
|
message: JSON.stringify(state),
|
||||||
|
targets: ['broadcast'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Buffer.byteLength(payload, 'utf8') > 5000) {
|
||||||
|
this.logger.warn({ message: 'pubsub payload too large, skipping', channelId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${PUBSUB_URL}/${channelId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Client-Id': clientId,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
this.logger.warn({
|
||||||
|
message: 'pubsub broadcast failed',
|
||||||
|
channelId,
|
||||||
|
status: res.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error({ message: 'pubsub broadcast error', channelId, err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildServerToken(clientId: string, secretB64: string): string {
|
||||||
|
const secretBytes = Buffer.from(secretB64, 'base64');
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS;
|
||||||
|
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||||
|
const payload = Buffer.from(
|
||||||
|
JSON.stringify({ exp, user_id: clientId, role: 'external' })
|
||||||
|
).toString('base64url');
|
||||||
|
|
||||||
|
const sig = createHmac('sha256', secretBytes)
|
||||||
|
.update(`${header}.${payload}`)
|
||||||
|
.digest('base64url');
|
||||||
|
|
||||||
|
return `${header}.${payload}.${sig}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
-
|
||||||
@@ -1,20 +1,33 @@
|
|||||||
/**
|
// OTel must be initialised before any other requires — do not move this line.
|
||||||
* This is not a production server yet!
|
import './tracing';
|
||||||
* This is only a minimal backend to get started.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from 'nestjs-pino';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
if (process.env['NODE_ENV'] === 'production' && process.env['DEV_AUTH_BYPASS']) {
|
||||||
|
throw new Error('DEV_AUTH_BYPASS must not be set in production');
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||||
|
app.useLogger(app.get(Logger));
|
||||||
|
|
||||||
|
const allowedOrigins =
|
||||||
|
process.env['NODE_ENV'] === 'development'
|
||||||
|
? ['http://localhost:4200', /https:\/\/.*\.ext-twitch\.tv$/]
|
||||||
|
: [/https:\/\/.*\.ext-twitch\.tv$/];
|
||||||
|
app.enableCors({ origin: allowedOrigins, credentials: true });
|
||||||
|
|
||||||
const globalPrefix = 'api';
|
const globalPrefix = 'api';
|
||||||
app.setGlobalPrefix(globalPrefix);
|
app.setGlobalPrefix(globalPrefix);
|
||||||
const port = process.env.PORT || 3000;
|
|
||||||
|
const port = process.env['PORT'] ?? 3000;
|
||||||
await app.listen(port, '0.0.0.0');
|
await app.listen(port, '0.0.0.0');
|
||||||
Logger.log(
|
|
||||||
`🚀 Application is running on: http://localhost:${port}/${globalPrefix}`,
|
app.get(Logger).log(
|
||||||
|
`Application running on http://localhost:${port}/${globalPrefix}`,
|
||||||
|
'bootstrap'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
apps/api/src/tracing.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* OpenTelemetry bootstrap — must be imported before any other app module.
|
||||||
|
* main.ts imports this as its first line.
|
||||||
|
*/
|
||||||
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||||
|
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
|
||||||
|
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
||||||
|
import { ConsoleSpanExporter } from '@opentelemetry/sdk-trace-node';
|
||||||
|
import { resourceFromAttributes } from '@opentelemetry/resources';
|
||||||
|
|
||||||
|
const exporter = process.env['OTEL_EXPORTER_OTLP_ENDPOINT']
|
||||||
|
? new OTLPTraceExporter() // reads OTEL_EXPORTER_OTLP_ENDPOINT from env
|
||||||
|
: new ConsoleSpanExporter();
|
||||||
|
|
||||||
|
const sdk = new NodeSDK({
|
||||||
|
resource: resourceFromAttributes({ 'service.name': 'fog-expedition-api' }),
|
||||||
|
traceExporter: exporter,
|
||||||
|
instrumentations: [
|
||||||
|
getNodeAutoInstrumentations({
|
||||||
|
// fs instrumentation is too noisy for a server with many module loads.
|
||||||
|
'@opentelemetry/instrumentation-fs': { enabled: false },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
sdk.start();
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
sdk.shutdown().finally(() => process.exit(0));
|
||||||
|
});
|
||||||
@@ -23,6 +23,12 @@
|
|||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "apps/overlay/src/environments/environment.ts",
|
||||||
|
"with": "apps/overlay/src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
],
|
||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
|
|||||||
BIN
apps/overlay/public/avatars/IconItems_chineseFirecracker.webp
Executable file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/overlay/public/avatars/IconItems_flashlight.webp
Executable file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
apps/overlay/public/avatars/IconItems_key.webp
Executable file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
apps/overlay/public/avatars/IconItems_map.webp
Executable file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
apps/overlay/public/avatars/IconItems_medkit.webp
Executable file
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/overlay/public/avatars/IconItems_toolbox.webp
Executable file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
apps/overlay/public/avatars/S01_DwightFairfield_Portrait.webp
Executable file
|
After Width: | Height: | Size: 81 KiB |
BIN
apps/overlay/public/avatars/S02_MegThomas_Portrait.webp
Executable file
|
After Width: | Height: | Size: 72 KiB |
BIN
apps/overlay/public/avatars/S03_ClaudetteMorel_Portrait.webp
Executable file
|
After Width: | Height: | Size: 67 KiB |
BIN
apps/overlay/public/avatars/S04_JakePark_Portrait.webp
Executable file
|
After Width: | Height: | Size: 91 KiB |
BIN
apps/overlay/public/avatars/S05_NeaKarlsson_Portrait.webp
Executable file
|
After Width: | Height: | Size: 59 KiB |
BIN
apps/overlay/public/avatars/S06_LaurieStrode_Portrait.webp
Executable file
|
After Width: | Height: | Size: 84 KiB |
BIN
apps/overlay/public/avatars/S07_AceVisconti_Portrait.webp
Executable file
|
After Width: | Height: | Size: 86 KiB |
BIN
apps/overlay/public/avatars/S08_BillOverbeck_Portrait.webp
Executable file
|
After Width: | Height: | Size: 76 KiB |
BIN
apps/overlay/public/avatars/S09_FengMin_Portrait.webp
Executable file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/overlay/public/avatars/S10_DavidKing_Portrait.webp
Executable file
|
After Width: | Height: | Size: 87 KiB |
BIN
apps/overlay/public/avatars/S11_QuentinSmith_Portrait.webp
Executable file
|
After Width: | Height: | Size: 78 KiB |
BIN
apps/overlay/public/avatars/S12_DavidTapp_Portrait.webp
Executable file
|
After Width: | Height: | Size: 72 KiB |
BIN
apps/overlay/public/avatars/S13_KateDenson_Portrait.webp
Executable file
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/overlay/public/avatars/S14_AdamFrancis_Portrait.webp
Executable file
|
After Width: | Height: | Size: 63 KiB |
BIN
apps/overlay/public/avatars/S15_JeffJohansen_Portrait.webp
Executable file
|
After Width: | Height: | Size: 98 KiB |
BIN
apps/overlay/public/avatars/S16_JaneRomero_Portrait.webp
Executable file
|
After Width: | Height: | Size: 75 KiB |
BIN
apps/overlay/public/avatars/S17_AshWilliams_Portrait.webp
Executable file
|
After Width: | Height: | Size: 74 KiB |
BIN
apps/overlay/public/avatars/S18_NancyWheeler_Portrait.webp
Executable file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/overlay/public/avatars/S19_SteveHarrington_Portrait.webp
Executable file
|
After Width: | Height: | Size: 72 KiB |
BIN
apps/overlay/public/avatars/S20_YuiKimura_Portrait.webp
Executable file
|
After Width: | Height: | Size: 82 KiB |
BIN
apps/overlay/public/avatars/S21_ZarinaKassir_Portrait.webp
Executable file
|
After Width: | Height: | Size: 70 KiB |
BIN
apps/overlay/public/avatars/S22_CherylMason_Portrait.webp
Executable file
|
After Width: | Height: | Size: 77 KiB |
BIN
apps/overlay/public/avatars/S23_FelixRichter_Portrait.webp
Executable file
|
After Width: | Height: | Size: 64 KiB |
BIN
apps/overlay/public/avatars/S24_ElodieRakoto_Portrait.webp
Executable file
|
After Width: | Height: | Size: 104 KiB |
BIN
apps/overlay/public/avatars/S25_Yun-JinLee_Portrait.webp
Executable file
|
After Width: | Height: | Size: 90 KiB |
BIN
apps/overlay/public/avatars/S26_JillValentine_Portrait.webp
Executable file
|
After Width: | Height: | Size: 65 KiB |
BIN
apps/overlay/public/avatars/S27_LeonScottKennedy_Portrait.webp
Executable file
|
After Width: | Height: | Size: 68 KiB |
BIN
apps/overlay/public/avatars/S28_MikaelaReid_Portrait.webp
Executable file
|
After Width: | Height: | Size: 85 KiB |
BIN
apps/overlay/public/avatars/S29_JonahVasquez_Portrait.webp
Executable file
|
After Width: | Height: | Size: 75 KiB |
BIN
apps/overlay/public/avatars/S30_YoichiAsakawa_Portrait.webp
Executable file
|
After Width: | Height: | Size: 59 KiB |
BIN
apps/overlay/public/avatars/S31_HaddieKaur_Portrait.webp
Executable file
|
After Width: | Height: | Size: 86 KiB |
BIN
apps/overlay/public/avatars/S32_AdaWong_Portrait.webp
Executable file
|
After Width: | Height: | Size: 64 KiB |
BIN
apps/overlay/public/avatars/S33_RebeccaChambers_Portrait.webp
Executable file
|
After Width: | Height: | Size: 78 KiB |
BIN
apps/overlay/public/avatars/S34_VittorioToscano_Portrait.webp
Executable file
|
After Width: | Height: | Size: 86 KiB |
BIN
apps/overlay/public/avatars/S35_ThalitaLyra_Portrait.webp
Executable file
|
After Width: | Height: | Size: 76 KiB |
BIN
apps/overlay/public/avatars/S36_RenatoLyra_Portrait.webp
Executable file
|
After Width: | Height: | Size: 71 KiB |
BIN
apps/overlay/public/avatars/S37_GabrielSoma_Portrait.webp
Executable file
|
After Width: | Height: | Size: 71 KiB |
BIN
apps/overlay/public/avatars/S38_NicolasCage_Portrait.webp
Executable file
|
After Width: | Height: | Size: 68 KiB |
BIN
apps/overlay/public/avatars/S39_EllenRipley_Portrait.webp
Executable file
|
After Width: | Height: | Size: 92 KiB |
BIN
apps/overlay/public/avatars/S40_AlanWake_Portrait.webp
Executable file
|
After Width: | Height: | Size: 87 KiB |
BIN
apps/overlay/public/avatars/S41_SableWard_Portrait.webp
Executable file
|
After Width: | Height: | Size: 82 KiB |
BIN
apps/overlay/public/avatars/S42_TheTroupe_Portrait.webp
Executable file
|
After Width: | Height: | Size: 103 KiB |
BIN
apps/overlay/public/avatars/S43_LaraCroft_Portrait.webp
Executable file
|
After Width: | Height: | Size: 60 KiB |
BIN
apps/overlay/public/avatars/S44_TrevorBelmont_Portrait.webp
Executable file
|
After Width: | Height: | Size: 74 KiB |
BIN
apps/overlay/public/avatars/S45_TaurieCain_Portrait.webp
Executable file
|
After Width: | Height: | Size: 87 KiB |
BIN
apps/overlay/public/avatars/S46_RickGrimes_Portrait.webp
Executable file
|
After Width: | Height: | Size: 75 KiB |
BIN
apps/overlay/public/avatars/S47_MichonneGrimes_Portrait.webp
Executable file
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/overlay/public/avatars/S48_VeeBoonyasak_Portrait.webp
Executable file
|
After Width: | Height: | Size: 79 KiB |
@@ -4,10 +4,13 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { authInterceptor } from './ebs/auth.interceptor';
|
import { authInterceptor } from './ebs/auth.interceptor';
|
||||||
|
import { EBS_BASE_URL } from './ebs/ebs-api.service';
|
||||||
|
import { environment } from '../environments/environment';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideHttpClient(withInterceptors([authInterceptor])),
|
provideHttpClient(withInterceptors([authInterceptor])),
|
||||||
|
{ provide: EBS_BASE_URL, useValue: environment.ebsBaseUrl },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,17 +45,17 @@ describe('EbsApiService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('startMission', () => {
|
describe('startMission', () => {
|
||||||
it('POSTs to /missions/start with difficulty', () => {
|
it('POSTs to /missions/start with difficulty and durationMinutes', () => {
|
||||||
service.startMission({ difficulty: 2 }).subscribe({ error: () => undefined });
|
service.startMission({ difficulty: 2, durationMinutes: 20 }).subscribe({ error: () => undefined });
|
||||||
|
|
||||||
const req = controller.expectOne(`${BASE}/missions/start`);
|
const req = controller.expectOne(`${BASE}/missions/start`);
|
||||||
expect(req.request.method).toBe('POST');
|
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' });
|
req.flush({ bad: 'shape' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws ZodError for invalid difficulty before sending request', () => {
|
it('throws ZodError for invalid request before sending', () => {
|
||||||
expect(() => service.startMission({ difficulty: 99 })).toThrow();
|
expect(() => service.startMission({ difficulty: 99, durationMinutes: 20 })).toThrow();
|
||||||
controller.expectNone(`${BASE}/missions/start`);
|
controller.expectNone(`${BASE}/missions/start`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
ChoosePerkRequestSchema,
|
ChoosePerkRequestSchema,
|
||||||
MissionStateResponse,
|
MissionStateResponse,
|
||||||
MissionStateResponseSchema,
|
MissionStateResponseSchema,
|
||||||
MissionSchema,
|
|
||||||
StartMissionRequest,
|
StartMissionRequest,
|
||||||
StartMissionRequestSchema,
|
StartMissionRequestSchema,
|
||||||
SurvivorSchema,
|
SurvivorSchema,
|
||||||
@@ -25,11 +24,21 @@ export class EbsApiService {
|
|||||||
.pipe(map((body) => MissionStateResponseSchema.parse(body)));
|
.pipe(map((body) => MissionStateResponseSchema.parse(body)));
|
||||||
}
|
}
|
||||||
|
|
||||||
startMission(req: StartMissionRequest) {
|
startMission(req: StartMissionRequest): Observable<NonNullable<MissionStateResponse>> {
|
||||||
StartMissionRequestSchema.parse(req);
|
StartMissionRequestSchema.parse(req);
|
||||||
return this.http
|
return this.http
|
||||||
.post(`${this.baseUrl}/missions/start`, req)
|
.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) {
|
choosePerk(req: ChoosePerkRequest) {
|
||||||
|
|||||||
@@ -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 { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
||||||
import { EbsApiService } from '../ebs/ebs-api.service';
|
import { EbsApiService } from '../ebs/ebs-api.service';
|
||||||
import { TwitchAuthService } from '../twitch/twitch-auth.service';
|
import { TwitchAuthService } from '../twitch/twitch-auth.service';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 20_000;
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class MissionStateStore {
|
export class MissionStateStore {
|
||||||
private readonly ebs = inject(EbsApiService);
|
private readonly ebs = inject(EbsApiService);
|
||||||
@@ -11,11 +13,30 @@ export class MissionStateStore {
|
|||||||
|
|
||||||
private readonly _state = signal<MissionStateResponse>(null);
|
private readonly _state = signal<MissionStateResponse>(null);
|
||||||
private readonly _loading = signal(false);
|
private readonly _loading = signal(false);
|
||||||
|
private readonly _starting = signal(false);
|
||||||
private readonly _error = signal<unknown>(null);
|
private readonly _error = signal<unknown>(null);
|
||||||
|
private readonly _initializing = signal(true);
|
||||||
|
|
||||||
readonly state = this._state.asReadonly();
|
readonly state = this._state.asReadonly();
|
||||||
readonly loading = this._loading.asReadonly();
|
readonly loading = this._loading.asReadonly();
|
||||||
|
readonly starting = this._starting.asReadonly();
|
||||||
readonly error = this._error.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 {
|
init(): void {
|
||||||
this.fetchState();
|
this.fetchState();
|
||||||
@@ -27,6 +48,31 @@ export class MissionStateStore {
|
|||||||
this.fetchState();
|
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 {
|
private fetchState(): void {
|
||||||
if (!this.authService.auth()) return;
|
if (!this.authService.auth()) return;
|
||||||
|
|
||||||
@@ -35,15 +81,28 @@ export class MissionStateStore {
|
|||||||
next: (state) => {
|
next: (state) => {
|
||||||
this._state.set(state);
|
this._state.set(state);
|
||||||
this._loading.set(false);
|
this._loading.set(false);
|
||||||
|
this._initializing.set(false);
|
||||||
this._error.set(null);
|
this._error.set(null);
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this._loading.set(false);
|
this._loading.set(false);
|
||||||
|
this._initializing.set(false);
|
||||||
this._error.set(err);
|
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 {
|
private subscribePubSub(): void {
|
||||||
if (!window.Twitch?.ext) return;
|
if (!window.Twitch?.ext) return;
|
||||||
|
|
||||||
|
|||||||
54
apps/overlay/src/app/panel/ambient-event.component.css
Normal 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;
|
||||||
|
}
|
||||||
8
apps/overlay/src/app/panel/ambient-event.component.html
Normal 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>
|
||||||
@@ -1,52 +1,27 @@
|
|||||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||||
|
|
||||||
export interface AmbientEventData {
|
export interface AmbientEventData {
|
||||||
type: 'injury' | 'sacrifice' | 'mission-complete' | 'perk-acquired';
|
type: 'injury' | 'sacrifice' | 'mission-complete' | 'perk-acquired';
|
||||||
description: string;
|
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({
|
@Component({
|
||||||
selector: 'app-ambient-event',
|
selector: 'app-ambient-event',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
styles: [`
|
templateUrl: './ambient-event.component.html',
|
||||||
:host { display: block; }
|
styleUrl: './ambient-event.component.css',
|
||||||
.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 {
|
export class AmbientEventComponent {
|
||||||
event = input.required<AmbientEventData>();
|
event = input.required<AmbientEventData>();
|
||||||
dismissed = output<void>();
|
dismissed = output<void>();
|
||||||
|
|
||||||
|
protected meta = computed(() => EVENT_META[this.event().type]);
|
||||||
}
|
}
|
||||||
|
|||||||
260
apps/overlay/src/app/panel/expanded-panel.component.css
Normal 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;
|
||||||
|
}
|
||||||
72
apps/overlay/src/app/panel/expanded-panel.component.html
Normal 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>
|
||||||
@@ -4,126 +4,65 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
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({
|
@Component({
|
||||||
selector: 'app-expanded-panel',
|
selector: 'app-expanded-panel',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
styles: [`
|
templateUrl: './expanded-panel.component.html',
|
||||||
:host { display: block; }
|
styleUrl: './expanded-panel.component.css',
|
||||||
.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 {
|
export class ExpandedPanelComponent {
|
||||||
missionState = input.required<NonNullable<MissionStateResponse>>();
|
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 mission = computed(() => this.missionState().mission);
|
||||||
protected survivor = computed(() => this.missionState().survivors[0] ?? null);
|
protected survivor = computed(() => this.missionState().survivors[0] ?? null);
|
||||||
protected recentLog = computed(() => [...this.missionState().recentLog].reverse());
|
protected initials = computed(() => {
|
||||||
protected difficultyGlyphs = computed(() =>
|
const name = this.missionState().survivors[0]?.name ?? '';
|
||||||
'◆'.repeat(this.missionState().mission.difficulty)
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
93
apps/overlay/src/app/panel/minimised-panel.component.css
Normal 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;
|
||||||
|
}
|
||||||
33
apps/overlay/src/app/panel/minimised-panel.component.html
Normal 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>
|
||||||
@@ -4,75 +4,53 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
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({
|
@Component({
|
||||||
selector: 'app-minimised-panel',
|
selector: 'app-minimised-panel',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
styles: [`
|
templateUrl: './minimised-panel.component.html',
|
||||||
:host { display: block; }
|
styleUrl: './minimised-panel.component.css',
|
||||||
.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 {
|
export class MinimisedPanelComponent {
|
||||||
missionState = input.required<NonNullable<MissionStateResponse>>();
|
missionState = input.required<NonNullable<MissionStateResponse>>();
|
||||||
lanternClick = output<void>();
|
lanternClick = output<void>();
|
||||||
|
|
||||||
protected survivorState = computed(
|
protected stateMeta = computed(() => {
|
||||||
() => this.missionState().survivors[0]?.state ?? 'idle'
|
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(() => {
|
protected latestLogLine = computed(() => {
|
||||||
const log = this.missionState().recentLog;
|
const log = this.missionState().recentLog;
|
||||||
return log.length ? log[log.length - 1].logText : null;
|
return log.length ? log[0].logText : null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
244
apps/overlay/src/app/panel/panel-shell.component.css
Normal 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; }
|
||||||
|
}
|
||||||
132
apps/overlay/src/app/panel/panel-shell.component.html
Normal 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…</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…</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()">← 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()">← 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()"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ class AmbientEventStub {
|
|||||||
@Component({ selector: 'app-expanded-panel', standalone: true, template: '' })
|
@Component({ selector: 'app-expanded-panel', standalone: true, template: '' })
|
||||||
class ExpandedPanelStub {
|
class ExpandedPanelStub {
|
||||||
@Input() missionState!: NonNullable<MissionStateResponse>;
|
@Input() missionState!: NonNullable<MissionStateResponse>;
|
||||||
@Output() close = new EventEmitter<void>();
|
@Output() panelClose = new EventEmitter<void>();
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE = 'https://test.local';
|
const BASE = 'https://test.local';
|
||||||
@@ -193,7 +193,7 @@ describe('PanelShellComponent', () => {
|
|||||||
|
|
||||||
// Emit close from the expanded stub
|
// Emit close from the expanded stub
|
||||||
fixture.debugElement.query(By.directive(ExpandedPanelStub))
|
fixture.debugElement.query(By.directive(ExpandedPanelStub))
|
||||||
.componentInstance.close.emit();
|
.componentInstance.panelClose.emit();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
|
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
computed,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
effect,
|
effect,
|
||||||
inject,
|
inject,
|
||||||
@@ -8,15 +9,18 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
untracked,
|
untracked,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||||
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
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 { distinctUntilChanged, filter, skip } from 'rxjs';
|
||||||
import { EbsApiService } from '../ebs/ebs-api.service';
|
|
||||||
import { MissionStateStore } from '../mission/mission-state.store';
|
import { MissionStateStore } from '../mission/mission-state.store';
|
||||||
import { TwitchAuthService } from '../twitch/twitch-auth.service';
|
import { TwitchAuthService } from '../twitch/twitch-auth.service';
|
||||||
import { AmbientEventComponent, AmbientEventData } from './ambient-event.component';
|
import { AmbientEventComponent, AmbientEventData } from './ambient-event.component';
|
||||||
import { ExpandedPanelComponent } from './expanded-panel.component';
|
import { ExpandedPanelComponent } from './expanded-panel.component';
|
||||||
import { MinimisedPanelComponent } from './minimised-panel.component';
|
import { MinimisedPanelComponent } from './minimised-panel.component';
|
||||||
|
import { SummaryPanelComponent } from './summary-panel.component';
|
||||||
|
|
||||||
type OverlayView = 'minimised' | 'ambient' | 'expanded';
|
type OverlayView = 'minimised' | 'ambient' | 'expanded';
|
||||||
|
|
||||||
@@ -45,76 +49,44 @@ function detectAmbientEvent(
|
|||||||
selector: 'app-panel-shell',
|
selector: 'app-panel-shell',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [MinimisedPanelComponent, AmbientEventComponent, ExpandedPanelComponent],
|
imports: [FormsModule, MinimisedPanelComponent, AmbientEventComponent, ExpandedPanelComponent, SummaryPanelComponent],
|
||||||
template: `
|
templateUrl: './panel-shell.component.html',
|
||||||
@if (authService.auth()) {
|
styleUrl: './panel-shell.component.css',
|
||||||
@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 {
|
export class PanelShellComponent implements OnInit {
|
||||||
protected readonly authService = inject(TwitchAuthService);
|
protected readonly authService = inject(TwitchAuthService);
|
||||||
protected readonly store = inject(MissionStateStore);
|
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);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
protected readonly view = signal<OverlayView>('minimised');
|
protected readonly view = signal<OverlayView>('minimised');
|
||||||
protected readonly pendingEvent = signal<AmbientEventData | null>(null);
|
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 prevState: MissionStateResponse = null;
|
||||||
private ambientTimer: ReturnType<typeof setTimeout> | null = null;
|
private ambientTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -134,6 +106,13 @@ export class PanelShellComponent implements OnInit {
|
|||||||
const current = this.store.state();
|
const current = this.store.state();
|
||||||
untracked(() => this.handleStateChange(current));
|
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 {
|
ngOnInit(): void {
|
||||||
@@ -145,6 +124,49 @@ export class PanelShellComponent implements OnInit {
|
|||||||
this.view.update((v) => (v === 'expanded' ? 'minimised' : 'expanded'));
|
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 {
|
protected onAmbientDismiss(): void {
|
||||||
this.clearAmbientTimer();
|
this.clearAmbientTimer();
|
||||||
this.view.set('minimised');
|
this.view.set('minimised');
|
||||||
|
|||||||
147
apps/overlay/src/app/panel/summary-panel.component.css
Normal 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;
|
||||||
|
}
|
||||||
34
apps/overlay/src/app/panel/summary-panel.component.html
Normal 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>
|
||||||
32
apps/overlay/src/app/panel/summary-panel.component.ts
Normal 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);
|
||||||
|
}
|
||||||
62
apps/overlay/src/app/panel/survivor-initials.ts
Normal 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;
|
||||||
|
}
|
||||||
5
apps/overlay/src/environments/environment.prod.ts
Normal 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',
|
||||||
|
};
|
||||||
6
apps/overlay/src/environments/environment.ts
Normal 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',
|
||||||
|
};
|
||||||