diff --git a/CLAUDE.md b/CLAUDE.md index 5fa0f54..c734dcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ This is a Twitch Video Overlay extension implementing an autonomous tick-based Z - Sentence case in UI text and log messages. - Zod schemas (not just TS types) at the EBS boundary. - Pure functions for game logic. Seeded PRNG via `seedrandom`, never `Math.random()`. Persist seeds in `mission_logs`. -- Migrations from day one. No "I'll add migrations later." +- Migrations from day one. No "I'll add migrations later." After writing a migration file, always run `pnpm exec prisma migrate deploy --schema=apps/api/prisma/schema.prisma` immediately — the regenerated Prisma client will SELECT new columns on every query and cause 500s until the column exists in the database. - Round every displayed number; JS float math leaks artifacts. - Structured logging with correlation IDs (`missionId`, `tickIndex`, `channelId`, `opaqueUserId`). - Bind Nest services to `0.0.0.0`, not `127.0.0.1`. The Twitch dev rig on Windows reaches them via the WSL2/devcontainer port forwarding chain. diff --git a/apps/api/prisma/migrations/20260511000000_add_mission_killer_name/migration.sql b/apps/api/prisma/migrations/20260511000000_add_mission_killer_name/migration.sql new file mode 100644 index 0000000..8ae444b --- /dev/null +++ b/apps/api/prisma/migrations/20260511000000_add_mission_killer_name/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "missions" ADD COLUMN "killer_name" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 7442efb..f197587 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -39,6 +39,7 @@ model Mission { difficulty Int @db.SmallInt durationMinutes Int @default(20) @map("duration_minutes") @db.SmallInt status String @default("active") + killerName String? @map("killer_name") encounterLibraryVersion String @map("encounter_library_version") startedAt DateTime @default(now()) @map("started_at") endedAt DateTime? @map("ended_at") diff --git a/apps/api/src/app/missions/encounter.service.ts b/apps/api/src/app/missions/encounter.service.ts index 62670ed..b5b7668 100644 --- a/apps/api/src/app/missions/encounter.service.ts +++ b/apps/api/src/app/missions/encounter.service.ts @@ -79,7 +79,7 @@ export class EncounterService { const libEncounter = getEncounterById(encounter.key); const flavor = libEncounter - ? pickFlavor(libEncounter, { success: result.success }, rng) + ? pickFlavor(libEncounter, { success: result.success, killerName: mission.killerName }, rng) : result.logText; newLog.push({ ...result, logText: flavor }); diff --git a/apps/api/src/app/missions/missions.service.ts b/apps/api/src/app/missions/missions.service.ts index bbb75e0..454fe79 100644 --- a/apps/api/src/app/missions/missions.service.ts +++ b/apps/api/src/app/missions/missions.service.ts @@ -10,7 +10,8 @@ import type { Survivor, SurvivorStats, } from '@fog-explorer/api-interfaces'; -import { getLibraryVersion } from '@fog-explorer/encounter-library'; +import { getLibraryVersion, KILLER_NAMES } from '@fog-explorer/encounter-library'; +import seedrandom = require('seedrandom'); import { TwitchJwtPayload } from '../auth/twitch-jwt.guard'; import { PrismaService } from '../prisma/prisma.service'; import { MissionStoreService } from './mission-store.service'; @@ -38,6 +39,7 @@ export class MissionsService { 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 killerName = pickKiller(missionId); // Upsert user and create survivor + mission in one transaction. await this.prisma.$transaction(async (tx) => { @@ -66,6 +68,7 @@ export class MissionsService { difficulty, durationMinutes, status: 'active', + killerName, encounterLibraryVersion: getLibraryVersion(), nextTickAt, participants: { @@ -98,6 +101,7 @@ export class MissionsService { difficulty, durationMinutes, status: 'active', + killerName, encounterLibraryVersion: getLibraryVersion(), nextTickAt: nextTickAt.toISOString(), tickIndex: 0, @@ -154,3 +158,8 @@ export class MissionsService { function defaultName(opaqueUserId: string): string { return `Survivor ${opaqueUserId.slice(-4)}`; } + +function pickKiller(missionId: string): string { + const rng = seedrandom(`killer:${missionId}`); + return KILLER_NAMES[Math.floor(rng() * KILLER_NAMES.length)]; +} diff --git a/apps/overlay/public/avatars/K01_TheTrapper_Portrait.webp b/apps/overlay/public/avatars/K01_TheTrapper_Portrait.webp new file mode 100755 index 0000000..5b010fd Binary files /dev/null and b/apps/overlay/public/avatars/K01_TheTrapper_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K01_charPreview_portrait.webp b/apps/overlay/public/avatars/K01_charPreview_portrait.webp new file mode 100755 index 0000000..ed24f6e Binary files /dev/null and b/apps/overlay/public/avatars/K01_charPreview_portrait.webp differ diff --git a/apps/overlay/public/avatars/K02_TheWraith_Portrait.webp b/apps/overlay/public/avatars/K02_TheWraith_Portrait.webp new file mode 100755 index 0000000..0d87671 Binary files /dev/null and b/apps/overlay/public/avatars/K02_TheWraith_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K03_TheHillbilly_Portrait.webp b/apps/overlay/public/avatars/K03_TheHillbilly_Portrait.webp new file mode 100755 index 0000000..469d9ed Binary files /dev/null and b/apps/overlay/public/avatars/K03_TheHillbilly_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K04_TheNurse_Portrait.webp b/apps/overlay/public/avatars/K04_TheNurse_Portrait.webp new file mode 100755 index 0000000..55271cc Binary files /dev/null and b/apps/overlay/public/avatars/K04_TheNurse_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K05_TheShape_Portrait.webp b/apps/overlay/public/avatars/K05_TheShape_Portrait.webp new file mode 100755 index 0000000..601eb49 Binary files /dev/null and b/apps/overlay/public/avatars/K05_TheShape_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K06_TheHag_Portrait.webp b/apps/overlay/public/avatars/K06_TheHag_Portrait.webp new file mode 100755 index 0000000..707d017 Binary files /dev/null and b/apps/overlay/public/avatars/K06_TheHag_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K07_TheDoctor_Portrait.webp b/apps/overlay/public/avatars/K07_TheDoctor_Portrait.webp new file mode 100755 index 0000000..de4ec30 Binary files /dev/null and b/apps/overlay/public/avatars/K07_TheDoctor_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K08_TheHuntress_Portrait.webp b/apps/overlay/public/avatars/K08_TheHuntress_Portrait.webp new file mode 100755 index 0000000..79d01d6 Binary files /dev/null and b/apps/overlay/public/avatars/K08_TheHuntress_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K09_TheCannibal_Portrait.webp b/apps/overlay/public/avatars/K09_TheCannibal_Portrait.webp new file mode 100755 index 0000000..9006459 Binary files /dev/null and b/apps/overlay/public/avatars/K09_TheCannibal_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K10_TheNightmare_Portrait.webp b/apps/overlay/public/avatars/K10_TheNightmare_Portrait.webp new file mode 100755 index 0000000..7a7d56e Binary files /dev/null and b/apps/overlay/public/avatars/K10_TheNightmare_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K11_ThePig_Portrait.webp b/apps/overlay/public/avatars/K11_ThePig_Portrait.webp new file mode 100755 index 0000000..786dec5 Binary files /dev/null and b/apps/overlay/public/avatars/K11_ThePig_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K12_TheClown_Portrait.webp b/apps/overlay/public/avatars/K12_TheClown_Portrait.webp new file mode 100755 index 0000000..a04f667 Binary files /dev/null and b/apps/overlay/public/avatars/K12_TheClown_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K13_TheSpirit_Portrait.webp b/apps/overlay/public/avatars/K13_TheSpirit_Portrait.webp new file mode 100755 index 0000000..fca97c7 Binary files /dev/null and b/apps/overlay/public/avatars/K13_TheSpirit_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K14_TheLegion_Portrait.webp b/apps/overlay/public/avatars/K14_TheLegion_Portrait.webp new file mode 100755 index 0000000..84753c8 Binary files /dev/null and b/apps/overlay/public/avatars/K14_TheLegion_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K15_ThePlague_Portrait.webp b/apps/overlay/public/avatars/K15_ThePlague_Portrait.webp new file mode 100755 index 0000000..d530cdf Binary files /dev/null and b/apps/overlay/public/avatars/K15_ThePlague_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K16_TheGhostFace_Portrait.webp b/apps/overlay/public/avatars/K16_TheGhostFace_Portrait.webp new file mode 100755 index 0000000..537c6d5 Binary files /dev/null and b/apps/overlay/public/avatars/K16_TheGhostFace_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K17_TheDemogorgon_Portrait.webp b/apps/overlay/public/avatars/K17_TheDemogorgon_Portrait.webp new file mode 100755 index 0000000..1c0da77 Binary files /dev/null and b/apps/overlay/public/avatars/K17_TheDemogorgon_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K18_TheOni_Portrait.webp b/apps/overlay/public/avatars/K18_TheOni_Portrait.webp new file mode 100755 index 0000000..a1a1e02 Binary files /dev/null and b/apps/overlay/public/avatars/K18_TheOni_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K19_TheDeathslinger_Portrait.webp b/apps/overlay/public/avatars/K19_TheDeathslinger_Portrait.webp new file mode 100755 index 0000000..dc0d48f Binary files /dev/null and b/apps/overlay/public/avatars/K19_TheDeathslinger_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K20_TheExecutioner_Portrait.webp b/apps/overlay/public/avatars/K20_TheExecutioner_Portrait.webp new file mode 100755 index 0000000..d6df474 Binary files /dev/null and b/apps/overlay/public/avatars/K20_TheExecutioner_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K21_TheBlight_Portrait.webp b/apps/overlay/public/avatars/K21_TheBlight_Portrait.webp new file mode 100755 index 0000000..f5b267f Binary files /dev/null and b/apps/overlay/public/avatars/K21_TheBlight_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K22_TheTwins_Portrait.webp b/apps/overlay/public/avatars/K22_TheTwins_Portrait.webp new file mode 100755 index 0000000..2054aa5 Binary files /dev/null and b/apps/overlay/public/avatars/K22_TheTwins_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K23_TheTrickster_Portrait.webp b/apps/overlay/public/avatars/K23_TheTrickster_Portrait.webp new file mode 100755 index 0000000..a00a032 Binary files /dev/null and b/apps/overlay/public/avatars/K23_TheTrickster_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K24_TheNemesis_Portrait.webp b/apps/overlay/public/avatars/K24_TheNemesis_Portrait.webp new file mode 100755 index 0000000..b105e56 Binary files /dev/null and b/apps/overlay/public/avatars/K24_TheNemesis_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K25_TheCenobite_Portrait.webp b/apps/overlay/public/avatars/K25_TheCenobite_Portrait.webp new file mode 100755 index 0000000..254713c Binary files /dev/null and b/apps/overlay/public/avatars/K25_TheCenobite_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K26_TheArtist_Portrait.webp b/apps/overlay/public/avatars/K26_TheArtist_Portrait.webp new file mode 100755 index 0000000..f9b78b1 Binary files /dev/null and b/apps/overlay/public/avatars/K26_TheArtist_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K27_TheOnryo_Portrait.webp b/apps/overlay/public/avatars/K27_TheOnryo_Portrait.webp new file mode 100755 index 0000000..f5ad464 Binary files /dev/null and b/apps/overlay/public/avatars/K27_TheOnryo_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K28_TheDredge_Portrait.webp b/apps/overlay/public/avatars/K28_TheDredge_Portrait.webp new file mode 100755 index 0000000..3053ee7 Binary files /dev/null and b/apps/overlay/public/avatars/K28_TheDredge_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K29_TheMastermind_Portrait.webp b/apps/overlay/public/avatars/K29_TheMastermind_Portrait.webp new file mode 100755 index 0000000..39f5252 Binary files /dev/null and b/apps/overlay/public/avatars/K29_TheMastermind_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K30_TheKnight_Portrait.webp b/apps/overlay/public/avatars/K30_TheKnight_Portrait.webp new file mode 100755 index 0000000..f5bf417 Binary files /dev/null and b/apps/overlay/public/avatars/K30_TheKnight_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K31_TheSkullMerchant_Portrait.webp b/apps/overlay/public/avatars/K31_TheSkullMerchant_Portrait.webp new file mode 100755 index 0000000..7514b96 Binary files /dev/null and b/apps/overlay/public/avatars/K31_TheSkullMerchant_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K32_TheSingularity_Portrait.webp b/apps/overlay/public/avatars/K32_TheSingularity_Portrait.webp new file mode 100755 index 0000000..2e515fb Binary files /dev/null and b/apps/overlay/public/avatars/K32_TheSingularity_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K33_TheXenomorph_Portrait.webp b/apps/overlay/public/avatars/K33_TheXenomorph_Portrait.webp new file mode 100755 index 0000000..b66ae1a Binary files /dev/null and b/apps/overlay/public/avatars/K33_TheXenomorph_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K34_TheGoodGuy_Portrait.webp b/apps/overlay/public/avatars/K34_TheGoodGuy_Portrait.webp new file mode 100755 index 0000000..2e98233 Binary files /dev/null and b/apps/overlay/public/avatars/K34_TheGoodGuy_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K35_TheUnknown_Portrait.webp b/apps/overlay/public/avatars/K35_TheUnknown_Portrait.webp new file mode 100755 index 0000000..85241a4 Binary files /dev/null and b/apps/overlay/public/avatars/K35_TheUnknown_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K36_TheLich_Portrait.webp b/apps/overlay/public/avatars/K36_TheLich_Portrait.webp new file mode 100755 index 0000000..e83ed4e Binary files /dev/null and b/apps/overlay/public/avatars/K36_TheLich_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K37_TheDarkLord_Portrait.webp b/apps/overlay/public/avatars/K37_TheDarkLord_Portrait.webp new file mode 100755 index 0000000..ef15dc5 Binary files /dev/null and b/apps/overlay/public/avatars/K37_TheDarkLord_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K38_TheHoundmaster_Portrait.webp b/apps/overlay/public/avatars/K38_TheHoundmaster_Portrait.webp new file mode 100755 index 0000000..a70490b Binary files /dev/null and b/apps/overlay/public/avatars/K38_TheHoundmaster_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K39_TheGhoul_Portrait.webp b/apps/overlay/public/avatars/K39_TheGhoul_Portrait.webp new file mode 100755 index 0000000..ad6d52b Binary files /dev/null and b/apps/overlay/public/avatars/K39_TheGhoul_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K40_TheAnimatronic_Portrait.webp b/apps/overlay/public/avatars/K40_TheAnimatronic_Portrait.webp new file mode 100755 index 0000000..537bd0d Binary files /dev/null and b/apps/overlay/public/avatars/K40_TheAnimatronic_Portrait.webp differ diff --git a/apps/overlay/public/avatars/K41_TheKrasue_Portrait.webp b/apps/overlay/public/avatars/K41_TheKrasue_Portrait.webp new file mode 100755 index 0000000..5a8dee1 Binary files /dev/null and b/apps/overlay/public/avatars/K41_TheKrasue_Portrait.webp differ diff --git a/apps/overlay/src/app/panel/expanded-panel.component.css b/apps/overlay/src/app/panel/expanded-panel.component.css index aa47b55..ad2c67a 100644 --- a/apps/overlay/src/app/panel/expanded-panel.component.css +++ b/apps/overlay/src/app/panel/expanded-panel.component.css @@ -160,11 +160,36 @@ text-transform: uppercase; } +.killer-vs { + display: flex; + align-items: center; + gap: 5px; + margin-left: auto; +} + +.killer-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + object-fit: cover; + object-position: top center; + opacity: 0.8; + flex-shrink: 0; +} + +.killer-name { + font-family: var(--font-mono); + font-size: 9.5px; + color: var(--fog-text-dim); + font-style: italic; + white-space: nowrap; +} + .tick-counter { font-family: var(--font-mono); font-size: 10px; color: var(--fog-text-dim); - margin-left: auto; + margin-left: 8px; } /* ── Log ── */ diff --git a/apps/overlay/src/app/panel/expanded-panel.component.html b/apps/overlay/src/app/panel/expanded-panel.component.html index c07df4d..617ca23 100644 --- a/apps/overlay/src/app/panel/expanded-panel.component.html +++ b/apps/overlay/src/app/panel/expanded-panel.component.html @@ -41,6 +41,19 @@ } Mission + @if (killerName(); as kn) { +
+ @if (killerAvatarSrc() && !killerImgError()) { + + } + vs {{ kn }} +
+ } T+{{ m.tickIndex }} } diff --git a/apps/overlay/src/app/panel/expanded-panel.component.ts b/apps/overlay/src/app/panel/expanded-panel.component.ts index 8f081e3..dc0b71c 100644 --- a/apps/overlay/src/app/panel/expanded-panel.component.ts +++ b/apps/overlay/src/app/panel/expanded-panel.component.ts @@ -7,6 +7,7 @@ import { signal, } from '@angular/core'; import { MissionStateResponse } from '@fog-explorer/api-interfaces'; +import { killerAvatarUrl } from '@fog-explorer/encounter-library'; import { avatarUrl, survivorInitials } from './survivor-initials'; /** Most-recent entry is index 0; older entries fade toward 0 opacity. */ @@ -41,6 +42,15 @@ export class ExpandedPanelComponent { protected readonly imgError = signal(false); + protected killerName = computed(() => this.missionState().mission.killerName ?? null); + + protected killerAvatarSrc = computed(() => { + const name = this.killerName(); + return name ? killerAvatarUrl(name) : null; + }); + + protected readonly killerImgError = signal(false); + protected survivorStateColor = computed(() => { const state = this.missionState().survivors[0]?.state ?? 'idle'; const map: Record = { diff --git a/apps/overlay/src/app/panel/minimised-panel.component.css b/apps/overlay/src/app/panel/minimised-panel.component.css index 7fef949..a94be38 100644 --- a/apps/overlay/src/app/panel/minimised-panel.component.css +++ b/apps/overlay/src/app/panel/minimised-panel.component.css @@ -1,12 +1,17 @@ :host { display: block; } .panel { + display: flex; + flex-direction: column; + width: 290px; + background: var(--fog-bg); +} + +.main-row { display: flex; align-items: center; gap: 10px; - width: 290px; height: 56px; - background: var(--fog-bg); padding: 0 12px 0 8px; } @@ -91,3 +96,33 @@ color: var(--fog-text-dim); font-style: italic; } + +/* ── Killer strip ── */ +.killer-strip { + display: flex; + align-items: center; + gap: 6px; + padding: 0 12px 7px 12px; + border-top: 1px solid var(--fog-border); + padding-top: 5px; +} + +.killer-thumb { + flex-shrink: 0; + width: 18px; + height: 18px; + border-radius: 50%; + object-fit: cover; + object-position: top center; + opacity: 0.75; +} + +.killer-label { + font-family: var(--font-mono); + font-size: 9.5px; + color: var(--fog-text-dim); + font-style: italic; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/apps/overlay/src/app/panel/minimised-panel.component.html b/apps/overlay/src/app/panel/minimised-panel.component.html index 2353db4..49494b6 100644 --- a/apps/overlay/src/app/panel/minimised-panel.component.html +++ b/apps/overlay/src/app/panel/minimised-panel.component.html @@ -1,33 +1,49 @@
-
- + +
+
+ @if (latestLogLine(); as line) { + {{ line }} } @else { - {{ initials() }} + The fog stirs… } - - -
-
- @if (latestLogLine(); as line) { - {{ line }} - } @else { - The fog stirs… - } +
+ + @if (killerName(); as kn) { +
+ @if (killerAvatarSrc() && !killerImgError()) { + + } + trial against {{ kn }} +
+ } diff --git a/apps/overlay/src/app/panel/minimised-panel.component.ts b/apps/overlay/src/app/panel/minimised-panel.component.ts index 149a10e..c57e314 100644 --- a/apps/overlay/src/app/panel/minimised-panel.component.ts +++ b/apps/overlay/src/app/panel/minimised-panel.component.ts @@ -7,6 +7,7 @@ import { signal, } from '@angular/core'; import { MissionStateResponse } from '@fog-explorer/api-interfaces'; +import { killerAvatarUrl } from '@fog-explorer/encounter-library'; import { avatarUrl, survivorInitials } from './survivor-initials'; /** @@ -49,6 +50,15 @@ export class MinimisedPanelComponent { protected readonly imgError = signal(false); + protected killerName = computed(() => this.missionState().mission.killerName ?? null); + + protected killerAvatarSrc = computed(() => { + const name = this.killerName(); + return name ? killerAvatarUrl(name) : null; + }); + + protected readonly killerImgError = signal(false); + protected latestLogLine = computed(() => { const log = this.missionState().recentLog; return log.length ? log[0].logText : null; diff --git a/apps/overlay/src/app/panel/panel-shell.component.css b/apps/overlay/src/app/panel/panel-shell.component.css index 50c3d60..7b263d1 100644 --- a/apps/overlay/src/app/panel/panel-shell.component.css +++ b/apps/overlay/src/app/panel/panel-shell.component.css @@ -149,15 +149,15 @@ 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; + color: var(--fog-text); + font-family: Verdana, Helvetica, Arial, sans-serif; + font-size: 11px; padding: 5px 8px; outline: none; transition: border-color 0.15s; } .character-filter::placeholder { - color: var(--fog-text-faint); + color: var(--fog-text-dim); font-style: italic; } .character-filter:focus { @@ -178,9 +178,9 @@ background: transparent; border: none; border-left: 2px solid transparent; - color: var(--fog-text-faint); - font-family: var(--font-mono); - font-size: 10px; + color: var(--fog-text-dim); + font-family: Verdana, Helvetica, Arial, sans-serif; + font-size: 11px; padding: 3px 8px; text-align: left; cursor: pointer; @@ -207,8 +207,8 @@ } .character-btn:hover, .character-btn:focus-visible { - color: var(--fog-text-dim); - border-left-color: var(--fog-amber-dim); + color: var(--fog-text); + border-left-color: var(--fog-amber); outline: none; } .character-btn--random { diff --git a/libs/api-interfaces/src/lib/mission.ts b/libs/api-interfaces/src/lib/mission.ts index 56b4d94..e0704db 100644 --- a/libs/api-interfaces/src/lib/mission.ts +++ b/libs/api-interfaces/src/lib/mission.ts @@ -31,6 +31,7 @@ export const MissionSchema = z.object({ difficulty: z.number().int().min(1).max(3), durationMinutes: MissionDurationMinutesSchema.default(20), status: MissionStateSchema, + killerName: z.string().optional(), encounterLibraryVersion: z.string().min(1), nextTickAt: z.iso.datetime(), tickIndex: z.number().int().min(0), diff --git a/libs/encounter-library/src/index.ts b/libs/encounter-library/src/index.ts index 8b0c578..6586d83 100644 --- a/libs/encounter-library/src/index.ts +++ b/libs/encounter-library/src/index.ts @@ -1,2 +1,3 @@ export * from './lib/encounter-library'; export { SURVIVOR_NAMES } from './lib/survivors'; +export { KILLERS, KILLER_NAMES, killerAvatarUrl } from './lib/killers'; diff --git a/libs/encounter-library/src/lib/encounter-library.ts b/libs/encounter-library/src/lib/encounter-library.ts index 9a8f013..db8b82e 100644 --- a/libs/encounter-library/src/lib/encounter-library.ts +++ b/libs/encounter-library/src/lib/encounter-library.ts @@ -1,5 +1,6 @@ import type { EncounterDefinition } from '@fog-explorer/api-interfaces'; import { ALLY_FIRST_NAMES } from './survivors'; +import { KILLER_NAMES } from './killers'; import encountersData from './encounters.json'; interface RawEncounter { @@ -23,6 +24,7 @@ export interface LibraryEncounter extends EncounterDefinition { export interface FlavorContext { success: boolean; + killerName?: string; } const LIBRARY_VERSION: string = encountersData.version; @@ -66,11 +68,15 @@ export function pickFlavor( ): string { const pool = ctx.success ? encounter.flavorSuccess : encounter.flavorFailure; const raw = pool[Math.floor(rng() * pool.length)]; - return expandAlly(raw, rng); + return expandTokens(raw, ctx, rng); } -function expandAlly(text: string, rng: () => number): string { - return text.replace(/\{\{ally\}\}/g, () => { +function expandTokens(text: string, ctx: FlavorContext, rng: () => number): string { + let out = text.replace(/\{\{ally\}\}/g, () => { return ALLY_FIRST_NAMES[Math.floor(rng() * ALLY_FIRST_NAMES.length)]; }); + out = out.replace(/\{\{killer\}\}/g, () => { + return ctx.killerName ?? KILLER_NAMES[Math.floor(rng() * KILLER_NAMES.length)]; + }); + return out; } diff --git a/libs/encounter-library/src/lib/encounters.json b/libs/encounter-library/src/lib/encounters.json index f969ccf..b60a010 100644 --- a/libs/encounter-library/src/lib/encounters.json +++ b/libs/encounter-library/src/lib/encounters.json @@ -1,5 +1,5 @@ { - "version": "1.2.0", + "version": "1.4.0", "encounters": [ { "key": "generator_repair", @@ -37,26 +37,26 @@ "tags": ["totem", "altruistic", "objectives"], "tier": 1, "flavorSuccess": [ - "It breaks. Something screams — not here, but somewhere.", - "The bones scatter. I feel the curse lift.", - "One hex gone. The fog feels slightly less wrong.", - "It comes apart like it was never solid.", - "I don't understand what I just broke. I'm glad I broke it.", - "{{ally}} spots it first. I make short work of it.", - "The skull gives. Whatever watched through it is gone.", - "{{ally}} watches my back. The cleanse is fast.", - "It was waiting to fall. I let it.", - "One less curse." + "A hex totem. I hear it before I see it. I break it anyway.", + "Just a dull totem. I cleanse it before it can become something worse.", + "The hex shatters. Whatever was draining us stops.", + "No curse on this one. Still worth clearing.", + "I don't know which hex it was. It's gone now. That's enough.", + "{{ally}} spots the hex. I make short work of it.", + "The glow fades. The hex is gone.", + "Dull totem. Cleansed. One less place for a hex to root itself.", + "{{ally}} calls it out — just a dull totem, but now it's ash.", + "One less totem in the trial." ], "flavorFailure": [ - "It pulls back. I can't finish this.", - "Something drives me away before it's done.", - "The hex holds. The air gets thicker.", - "I reach for it and stop. Something warns me off.", - "The totem hums. I'm not strong enough. Not yet.", - "{{ally}} calls wrong. The totem burns brighter.", - "The bones won't break. Wrong approach.", - "{{ally}} needs me. I leave the totem standing.", + "The hex totem pulls back. I can't finish this.", + "I reach it but something drives me off before it breaks.", + "The hex holds. The pressure doesn't lift.", + "Just a dull totem, but I can't stay long enough to cleanse it.", + "The hex hums. I'm not strong enough. Not yet.", + "{{ally}} calls wrong. The hex burns brighter.", + "No curse here, but no time either. I leave it standing.", + "{{ally}} needs me. The totem stays.", "Halfway through. Then something changes. I run.", "The hex endures. I'll find another way." ] @@ -160,25 +160,25 @@ "I don't breathe. I don't think. It passes.", "A long silence — then the footsteps recede.", "I melt into the fog. Unseen.", - "Predictable. Predictable is avoidable.", + "{{killer}} is predictable. Predictable is avoidable.", "Close. Too close. But not enough.", "{{ally}} draws the patrol the other way. I slip through.", "A gap in the route. I take it.", - "The killer looks elsewhere. I don't waste it.", + "{{killer}} looks elsewhere. I don't waste it.", "Footsteps near. Then far. Then gone.", "The locker is cold and tight and it works." ], "flavorFailure": [ - "A wrong step. Eye contact. The chase starts.", + "A wrong step. Eye contact. {{killer}} gives chase.", "I misjudge the angle. Spotted.", - "My heartbeat is too loud. The presence is too close.", + "My heartbeat is too loud. {{killer}} is too close.", "The route changed. I didn't know.", "No cover. Nowhere to go.", "{{ally}} breaks stealth nearby. We're both compromised.", "Every instinct says run. That instinct is wrong. I run anyway.", "The fog doesn't protect me here.", "Spotted. Everything changes.", - "{{ally}}'s noise pulls the killer my way." + "{{ally}}'s noise pulls {{killer}} my way." ] }, { @@ -214,31 +214,31 @@ { "key": "pallet_drop", "baseProbability": 0.55, - "tags": ["survival", "escape"], + "tags": ["survival", "chase"], "tier": 2, "flavorSuccess": [ - "It crashes down between us. A moment bought.", - "Wood and impact. I gain distance.", - "The drop lands right. The chase falters.", - "Timed right. The pallet works.", - "They stagger. I use every second.", - "The drop lands at the right instant.", - "A barrier placed. The chase interrupted.", - "It holds. That's all it needs to do.", - "A roar behind the pallet. I'm already running.", - "The gap widens. Use it." + "{{killer}} is right behind me. I drop the pallet at the right instant. Distance bought.", + "Mid-chase. The wood crashes down between us. I gain ground.", + "I run the loop and hit the pallet at the last second. {{killer}} staggers.", + "Timed right. The pallet interrupts the chase.", + "{{killer}} tries to mindgame the drop. I read it. The pallet lands.", + "The chase breaks. The pallet did its job.", + "A roar of frustration behind me. I'm already running.", + "The gap widens. I don't look back.", + "{{killer}} can't close it. The pallet holds them.", + "I break the chase. For now." ], "flavorFailure": [ - "The pallet drops wide. No gap.", - "Too slow. It means nothing.", - "I miscalculate. The chase continues.", - "Too early. Wasted.", - "They vault before the wood lands.", - "Wrong angle. Wrong moment.", - "The swing comes first.", - "Not enough distance. Never was.", - "I panic. The pallet follows.", - "Wasted. The chase doesn't stop." + "{{killer}} predicts the drop. I waste the pallet.", + "Too slow — {{killer}} is through before the wood lands.", + "I panic mid-chase. The pallet falls early. Useless.", + "{{killer}} vaults before the drop. The chase continues.", + "Wrong angle. The pallet saves nothing.", + "The chase doesn't stop. I'm out of pallets.", + "The swing comes before the wood hits the ground.", + "I misjudge the distance. Wasted.", + "One less pallet. The chase goes on.", + "The drop panics. So do I." ] }, { @@ -304,31 +304,31 @@ { "key": "window_vault", "baseProbability": 0.60, - "tags": ["escape", "survival"], + "tags": ["survival", "chase"], "tier": 1, "flavorSuccess": [ - "Clean vault. The frame holds.", - "Through and clear. Momentum kept.", - "Tight, but usable.", - "I make it through before they close the gap.", - "The vault buys distance. I use it.", - "Practiced. The window yields.", - "I land clean on the other side.", - "The window is a door when I need it.", - "Fast enough. The gap opens.", - "Through. The chase resets." + "{{killer}} is closing in. I hit the window before they grab me.", + "Clean vault. I reset the chase distance.", + "In a chase and the window is right there. I take it.", + "I make it through before {{killer}} can follow cleanly.", + "The vault buys a second. In a chase, that's everything.", + "Practiced. Through before {{killer}} can react.", + "I land clean. The chase tilts my way.", + "The window resets the loop. I use it.", + "Fast vault. The gap opens.", + "I break the chase line. Through and running." ], "flavorFailure": [ - "Boarded. No exit here.", - "Too slow. I'm caught mid-vault.", - "The frame splinters wrong.", - "I misjudge the height. I pay for it.", - "The window doesn't solve the problem.", - "Too narrow.", - "The worst possible moment for this to fail.", - "The opening was an illusion.", - "Bad angle. Bad outcome.", - "The window doesn't help tonight." + "Boarded. I lose the chase line.", + "Too slow. {{killer}} catches me mid-vault.", + "I misjudge the height. Mid-chase, that's fatal.", + "The window doesn't save me here.", + "Too narrow. Wrong call in a chase.", + "The vault fails at the worst moment.", + "{{killer}} reads the vault. I gain nothing.", + "Bad angle. The chase continues on their terms.", + "Blocked. I'm out of options on this loop.", + "The window doesn't help. The chase ends badly." ] }, { @@ -398,27 +398,27 @@ "tier": 2, "flavorSuccess": [ "{{ally}} gets there first. Both of us run.", - "Clean unhook. Nobody watching.", + "Clean unhook. {{killer}} not watching.", "I get them down. The trial continues.", "Fast hands, right timing.", "They're off the hook. We move.", - "{{ally}} pulls them free before the killer returns.", + "{{ally}} pulls them free before {{killer}} returns.", "The rescue works. Don't stop moving.", "The window was there. I took it.", "{{ally}} gets them clear. Two of us running again.", "Free. Both running now." ], "flavorFailure": [ - "{{ally}} pulls back. The killer turns too soon.", + "{{ally}} pulls back. {{killer}} turns too soon.", "The rescue fails. Both of us in danger.", "Wrong timing.", - "{{ally}} reaches for the hook — the killer is already watching.", + "{{ally}} reaches for the hook — {{killer}} is already watching.", "No safe window. I leave.", "The hook holds.", "{{ally}}'s approach is spotted.", "They're hooked again. Worse now.", "The rescue was a trap.", - "The killer was closer than it looked." + "{{killer}} was closer than it looked." ] }, { @@ -578,27 +578,87 @@ "tier": 3, "flavorSuccess": [ "The instinct misfires. I'm not found.", - "They look in the wrong place.", + "{{killer}} looks in the wrong place.", "The prediction fails. I'm safe.", - "They check elsewhere.", + "{{killer}} checks elsewhere.", "Wrong corner. I breathe.", - "They're certain. They're wrong.", + "{{killer}} is certain. {{killer}} is wrong.", "The read was off. I move.", "Not there. Not tonight.", - "The instinct fails them.", - "Their focus breaks. I use it." + "{{killer}}'s instinct fails them.", + "{{killer}}'s focus breaks. I use it." ], "flavorFailure": [ - "They know exactly where I am.", + "{{killer}} knows exactly where I am.", "Instinct and experience. I'm found.", - "There's no hiding from something this certain.", + "There's no hiding from {{killer}}.", "The read was right.", "My position is given away before the search starts.", "Exactly where expected.", - "Their certainty was earned.", + "{{killer}}'s certainty was earned.", "Found before the search begins.", "The instinct is right.", - "They walk directly to me." + "{{killer}} walks directly to me." + ] + }, + { + "key": "chase", + "baseProbability": 0.50, + "tags": ["survival", "chase"], + "tier": 2, + "flavorSuccess": [ + "I run the loop. {{killer}} can't close the gap. I lose them.", + "Three pallets, two windows. I get out of the chase clean.", + "I break line of sight in the fog. {{killer}}'s terror radius fades.", + "I outlast the chase. {{killer}} gives up.", + "My legs burn. I find cover. The chase ends.", + "I played the tiles right. The chase breaks in my favour.", + "Down to one pallet and I make it count. Chase over.", + "I dodge into the fog. {{killer}} can't read where I went.", + "The terror radius fades. I'm clear.", + "I survive the chase. Barely." + ], + "flavorFailure": [ + "I run out of pallets. {{killer}} closes the gap.", + "One wrong turn. Cornered.", + "{{killer}} reads my loop before I complete it.", + "I panic. I lose the tiles. That's it.", + "No more pallets. No windows left. I go down.", + "{{killer}} predicts every move I have.", + "I run until there's nowhere left to run.", + "Caught mid-vault. That's all it takes.", + "The terror radius never fades. {{killer}} catches me.", + "{{killer}} closes the distance. I can't stop it." + ] + }, + { + "key": "hook_sabotage", + "baseProbability": 0.40, + "tags": ["altruistic", "objectives"], + "tier": 2, + "flavorSuccess": [ + "I see {{killer}} carrying {{ally}}. I sprint to the nearest hook and sabo it before they arrive.", + "The hook comes apart in my hands. {{killer}} will have to carry them further.", + "I make it in time. One hook down.", + "Quick hands. The hook drops before {{killer}} turns the corner.", + "{{ally}} is being carried. The hook falls. Bought them a few more seconds.", + "Sabotaged and gone before anyone sees me.", + "The hook gives easily. I don't stay to watch.", + "One less hook {{killer}} can use.", + "I hear them struggling close by. I break the nearest hook and run.", + "The sabo lands. {{killer}} has to reroute. Use the time." + ], + "flavorFailure": [ + "Too slow. The hook is used before I get there.", + "I'm spotted mid-sabo. {{killer}} gives chase.", + "{{killer}} changes direction. They find a different hook.", + "I reach it but I'm too late to finish the sabo.", + "I start to sabo — then {{killer}}'s footsteps get too close.", + "{{ally}} is hooked before I can stop it.", + "{{killer}} anticipates it. They reroute.", + "Not fast enough. The hook holds.", + "{{ally}} goes up on the hook. I was too far.", + "The sabo fails. So does the rescue." ] } ] diff --git a/libs/encounter-library/src/lib/killers.ts b/libs/encounter-library/src/lib/killers.ts new file mode 100644 index 0000000..9f40888 --- /dev/null +++ b/libs/encounter-library/src/lib/killers.ts @@ -0,0 +1,55 @@ +export interface KillerEntry { + name: string; + file: string; +} + +export const KILLERS: KillerEntry[] = [ + { name: 'the Trapper', file: 'K01_TheTrapper_Portrait.webp' }, + { name: 'the Wraith', file: 'K02_TheWraith_Portrait.webp' }, + { name: 'the Hillbilly', file: 'K03_TheHillbilly_Portrait.webp' }, + { name: 'the Nurse', file: 'K04_TheNurse_Portrait.webp' }, + { name: 'the Shape', file: 'K05_TheShape_Portrait.webp' }, + { name: 'the Hag', file: 'K06_TheHag_Portrait.webp' }, + { name: 'the Doctor', file: 'K07_TheDoctor_Portrait.webp' }, + { name: 'the Huntress', file: 'K08_TheHuntress_Portrait.webp' }, + { name: 'the Cannibal', file: 'K09_TheCannibal_Portrait.webp' }, + { name: 'the Nightmare', file: 'K10_TheNightmare_Portrait.webp' }, + { name: 'the Pig', file: 'K11_ThePig_Portrait.webp' }, + { name: 'the Clown', file: 'K12_TheClown_Portrait.webp' }, + { name: 'the Spirit', file: 'K13_TheSpirit_Portrait.webp' }, + { name: 'the Legion', file: 'K14_TheLegion_Portrait.webp' }, + { name: 'the Plague', file: 'K15_ThePlague_Portrait.webp' }, + { name: 'the Ghost Face', file: 'K16_TheGhostFace_Portrait.webp' }, + { name: 'the Demogorgon', file: 'K17_TheDemogorgon_Portrait.webp' }, + { name: 'the Oni', file: 'K18_TheOni_Portrait.webp' }, + { name: 'the Deathslinger', file: 'K19_TheDeathslinger_Portrait.webp' }, + { name: 'the Executioner', file: 'K20_TheExecutioner_Portrait.webp' }, + { name: 'the Blight', file: 'K21_TheBlight_Portrait.webp' }, + { name: 'the Twins', file: 'K22_TheTwins_Portrait.webp' }, + { name: 'the Trickster', file: 'K23_TheTrickster_Portrait.webp' }, + { name: 'the Nemesis', file: 'K24_TheNemesis_Portrait.webp' }, + { name: 'the Cenobite', file: 'K25_TheCenobite_Portrait.webp' }, + { name: 'the Artist', file: 'K26_TheArtist_Portrait.webp' }, + { name: 'the Onryō', file: 'K27_TheOnryo_Portrait.webp' }, + { name: 'the Dredge', file: 'K28_TheDredge_Portrait.webp' }, + { name: 'the Mastermind', file: 'K29_TheMastermind_Portrait.webp' }, + { name: 'the Knight', file: 'K30_TheKnight_Portrait.webp' }, + { name: 'the Skull Merchant', file: 'K31_TheSkullMerchant_Portrait.webp' }, + { name: 'the Singularity', file: 'K32_TheSingularity_Portrait.webp' }, + { name: 'the Xenomorph', file: 'K33_TheXenomorph_Portrait.webp' }, + { name: 'the Good Guy', file: 'K34_TheGoodGuy_Portrait.webp' }, + { name: 'the Unknown', file: 'K35_TheUnknown_Portrait.webp' }, + { name: 'the Lich', file: 'K36_TheLich_Portrait.webp' }, + { name: 'the Dark Lord', file: 'K37_TheDarkLord_Portrait.webp' }, + { name: 'the Houndmaster', file: 'K38_TheHoundmaster_Portrait.webp' }, + { name: 'the Ghoul', file: 'K39_TheGhoul_Portrait.webp' }, + { name: 'the Animatronic', file: 'K40_TheAnimatronic_Portrait.webp' }, + { name: 'the Krasue', file: 'K41_TheKrasue_Portrait.webp' }, +]; + +export const KILLER_NAMES: string[] = KILLERS.map((k) => k.name); + +export function killerAvatarUrl(name: string): string | null { + const entry = KILLERS.find((k) => k.name === name); + return entry ? `avatars/${entry.file}` : null; +} diff --git a/libs/encounter-library/tsconfig.lib.json b/libs/encounter-library/tsconfig.lib.json index f12451e..ebe2492 100644 --- a/libs/encounter-library/tsconfig.lib.json +++ b/libs/encounter-library/tsconfig.lib.json @@ -4,7 +4,8 @@ "outDir": "../../dist/out-tsc", "declaration": true, "types": ["node"], - "resolveJsonModule": true + "resolveJsonModule": true, + "esModuleInterop": true }, "include": ["src/**/*.ts"], "exclude": [