Compare commits
1 Commits
0031ef0a8f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0517202412 |
@@ -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.
|
- Sentence case in UI text and log messages.
|
||||||
- Zod schemas (not just TS types) at the EBS boundary.
|
- 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`.
|
- 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.
|
- Round every displayed number; JS float math leaks artifacts.
|
||||||
- Structured logging with correlation IDs (`missionId`, `tickIndex`, `channelId`, `opaqueUserId`).
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "missions" ADD COLUMN "killer_name" TEXT;
|
||||||
@@ -39,6 +39,7 @@ model Mission {
|
|||||||
difficulty Int @db.SmallInt
|
difficulty Int @db.SmallInt
|
||||||
durationMinutes Int @default(20) @map("duration_minutes") @db.SmallInt
|
durationMinutes Int @default(20) @map("duration_minutes") @db.SmallInt
|
||||||
status String @default("active")
|
status String @default("active")
|
||||||
|
killerName String? @map("killer_name")
|
||||||
encounterLibraryVersion String @map("encounter_library_version")
|
encounterLibraryVersion String @map("encounter_library_version")
|
||||||
startedAt DateTime @default(now()) @map("started_at")
|
startedAt DateTime @default(now()) @map("started_at")
|
||||||
endedAt DateTime? @map("ended_at")
|
endedAt DateTime? @map("ended_at")
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export class EncounterService {
|
|||||||
|
|
||||||
const libEncounter = getEncounterById(encounter.key);
|
const libEncounter = getEncounterById(encounter.key);
|
||||||
const flavor = libEncounter
|
const flavor = libEncounter
|
||||||
? pickFlavor(libEncounter, { success: result.success }, rng)
|
? pickFlavor(libEncounter, { success: result.success, killerName: mission.killerName }, rng)
|
||||||
: result.logText;
|
: result.logText;
|
||||||
|
|
||||||
newLog.push({ ...result, logText: flavor });
|
newLog.push({ ...result, logText: flavor });
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import type {
|
|||||||
Survivor,
|
Survivor,
|
||||||
SurvivorStats,
|
SurvivorStats,
|
||||||
} from '@fog-explorer/api-interfaces';
|
} 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 { TwitchJwtPayload } from '../auth/twitch-jwt.guard';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { MissionStoreService } from './mission-store.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 nextTickAt = new Date(now.getTime() + TICK_BASE_INTERVAL_MS + jitter);
|
||||||
const stats: SurvivorStats = { objectives: 5, survival: 5, altruism: 5 };
|
const stats: SurvivorStats = { objectives: 5, survival: 5, altruism: 5 };
|
||||||
const survivorName = characterName ?? defaultName(claims.opaque_user_id);
|
const survivorName = characterName ?? defaultName(claims.opaque_user_id);
|
||||||
|
const killerName = pickKiller(missionId);
|
||||||
|
|
||||||
// Upsert user and create survivor + mission in one transaction.
|
// Upsert user and create survivor + mission in one transaction.
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.$transaction(async (tx) => {
|
||||||
@@ -66,6 +68,7 @@ export class MissionsService {
|
|||||||
difficulty,
|
difficulty,
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
killerName,
|
||||||
encounterLibraryVersion: getLibraryVersion(),
|
encounterLibraryVersion: getLibraryVersion(),
|
||||||
nextTickAt,
|
nextTickAt,
|
||||||
participants: {
|
participants: {
|
||||||
@@ -98,6 +101,7 @@ export class MissionsService {
|
|||||||
difficulty,
|
difficulty,
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
killerName,
|
||||||
encounterLibraryVersion: getLibraryVersion(),
|
encounterLibraryVersion: getLibraryVersion(),
|
||||||
nextTickAt: nextTickAt.toISOString(),
|
nextTickAt: nextTickAt.toISOString(),
|
||||||
tickIndex: 0,
|
tickIndex: 0,
|
||||||
@@ -154,3 +158,8 @@ export class MissionsService {
|
|||||||
function defaultName(opaqueUserId: string): string {
|
function defaultName(opaqueUserId: string): string {
|
||||||
return `Survivor ${opaqueUserId.slice(-4)}`;
|
return `Survivor ${opaqueUserId.slice(-4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickKiller(missionId: string): string {
|
||||||
|
const rng = seedrandom(`killer:${missionId}`);
|
||||||
|
return KILLER_NAMES[Math.floor(rng() * KILLER_NAMES.length)];
|
||||||
|
}
|
||||||
|
|||||||
BIN
apps/overlay/public/avatars/K01_TheTrapper_Portrait.webp
Executable file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/overlay/public/avatars/K01_charPreview_portrait.webp
Executable file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
apps/overlay/public/avatars/K02_TheWraith_Portrait.webp
Executable file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/overlay/public/avatars/K03_TheHillbilly_Portrait.webp
Executable file
|
After Width: | Height: | Size: 98 KiB |
BIN
apps/overlay/public/avatars/K04_TheNurse_Portrait.webp
Executable file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/overlay/public/avatars/K05_TheShape_Portrait.webp
Executable file
|
After Width: | Height: | Size: 86 KiB |
BIN
apps/overlay/public/avatars/K06_TheHag_Portrait.webp
Executable file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/overlay/public/avatars/K07_TheDoctor_Portrait.webp
Executable file
|
After Width: | Height: | Size: 95 KiB |
BIN
apps/overlay/public/avatars/K08_TheHuntress_Portrait.webp
Executable file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/overlay/public/avatars/K09_TheCannibal_Portrait.webp
Executable file
|
After Width: | Height: | Size: 104 KiB |
BIN
apps/overlay/public/avatars/K10_TheNightmare_Portrait.webp
Executable file
|
After Width: | Height: | Size: 98 KiB |
BIN
apps/overlay/public/avatars/K11_ThePig_Portrait.webp
Executable file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/overlay/public/avatars/K12_TheClown_Portrait.webp
Executable file
|
After Width: | Height: | Size: 102 KiB |
BIN
apps/overlay/public/avatars/K13_TheSpirit_Portrait.webp
Executable file
|
After Width: | Height: | Size: 94 KiB |
BIN
apps/overlay/public/avatars/K14_TheLegion_Portrait.webp
Executable file
|
After Width: | Height: | Size: 96 KiB |
BIN
apps/overlay/public/avatars/K15_ThePlague_Portrait.webp
Executable file
|
After Width: | Height: | Size: 128 KiB |
BIN
apps/overlay/public/avatars/K16_TheGhostFace_Portrait.webp
Executable file
|
After Width: | Height: | Size: 84 KiB |
BIN
apps/overlay/public/avatars/K17_TheDemogorgon_Portrait.webp
Executable file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/overlay/public/avatars/K18_TheOni_Portrait.webp
Executable file
|
After Width: | Height: | Size: 128 KiB |
BIN
apps/overlay/public/avatars/K19_TheDeathslinger_Portrait.webp
Executable file
|
After Width: | Height: | Size: 92 KiB |
BIN
apps/overlay/public/avatars/K20_TheExecutioner_Portrait.webp
Executable file
|
After Width: | Height: | Size: 86 KiB |
BIN
apps/overlay/public/avatars/K21_TheBlight_Portrait.webp
Executable file
|
After Width: | Height: | Size: 114 KiB |
BIN
apps/overlay/public/avatars/K22_TheTwins_Portrait.webp
Executable file
|
After Width: | Height: | Size: 105 KiB |
BIN
apps/overlay/public/avatars/K23_TheTrickster_Portrait.webp
Executable file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/overlay/public/avatars/K24_TheNemesis_Portrait.webp
Executable file
|
After Width: | Height: | Size: 114 KiB |
BIN
apps/overlay/public/avatars/K25_TheCenobite_Portrait.webp
Executable file
|
After Width: | Height: | Size: 94 KiB |
BIN
apps/overlay/public/avatars/K26_TheArtist_Portrait.webp
Executable file
|
After Width: | Height: | Size: 102 KiB |
BIN
apps/overlay/public/avatars/K27_TheOnryo_Portrait.webp
Executable file
|
After Width: | Height: | Size: 72 KiB |
BIN
apps/overlay/public/avatars/K28_TheDredge_Portrait.webp
Executable file
|
After Width: | Height: | Size: 100 KiB |
BIN
apps/overlay/public/avatars/K29_TheMastermind_Portrait.webp
Executable file
|
After Width: | Height: | Size: 82 KiB |
BIN
apps/overlay/public/avatars/K30_TheKnight_Portrait.webp
Executable file
|
After Width: | Height: | Size: 108 KiB |
BIN
apps/overlay/public/avatars/K31_TheSkullMerchant_Portrait.webp
Executable file
|
After Width: | Height: | Size: 90 KiB |
BIN
apps/overlay/public/avatars/K32_TheSingularity_Portrait.webp
Executable file
|
After Width: | Height: | Size: 108 KiB |
BIN
apps/overlay/public/avatars/K33_TheXenomorph_Portrait.webp
Executable file
|
After Width: | Height: | Size: 98 KiB |
BIN
apps/overlay/public/avatars/K34_TheGoodGuy_Portrait.webp
Executable file
|
After Width: | Height: | Size: 81 KiB |
BIN
apps/overlay/public/avatars/K35_TheUnknown_Portrait.webp
Executable file
|
After Width: | Height: | Size: 92 KiB |
BIN
apps/overlay/public/avatars/K36_TheLich_Portrait.webp
Executable file
|
After Width: | Height: | Size: 97 KiB |
BIN
apps/overlay/public/avatars/K37_TheDarkLord_Portrait.webp
Executable file
|
After Width: | Height: | Size: 93 KiB |
BIN
apps/overlay/public/avatars/K38_TheHoundmaster_Portrait.webp
Executable file
|
After Width: | Height: | Size: 95 KiB |
BIN
apps/overlay/public/avatars/K39_TheGhoul_Portrait.webp
Executable file
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/overlay/public/avatars/K40_TheAnimatronic_Portrait.webp
Executable file
|
After Width: | Height: | Size: 90 KiB |
BIN
apps/overlay/public/avatars/K41_TheKrasue_Portrait.webp
Executable file
|
After Width: | Height: | Size: 108 KiB |
@@ -160,11 +160,36 @@
|
|||||||
text-transform: uppercase;
|
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 {
|
.tick-counter {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--fog-text-dim);
|
color: var(--fog-text-dim);
|
||||||
margin-left: auto;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Log ── */
|
/* ── Log ── */
|
||||||
|
|||||||
@@ -41,6 +41,19 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<span class="mission-label">Mission</span>
|
<span class="mission-label">Mission</span>
|
||||||
|
@if (killerName(); as kn) {
|
||||||
|
<div class="killer-vs">
|
||||||
|
@if (killerAvatarSrc() && !killerImgError()) {
|
||||||
|
<img
|
||||||
|
class="killer-thumb"
|
||||||
|
[src]="killerAvatarSrc()!"
|
||||||
|
[alt]="kn"
|
||||||
|
(error)="killerImgError.set(true)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<span class="killer-name">vs {{ kn }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<span class="tick-counter">T+{{ m.tickIndex }}</span>
|
<span class="tick-counter">T+{{ m.tickIndex }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
||||||
|
import { killerAvatarUrl } from '@fog-explorer/encounter-library';
|
||||||
import { avatarUrl, survivorInitials } from './survivor-initials';
|
import { avatarUrl, survivorInitials } from './survivor-initials';
|
||||||
|
|
||||||
/** Most-recent entry is index 0; older entries fade toward 0 opacity. */
|
/** 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 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(() => {
|
protected survivorStateColor = computed(() => {
|
||||||
const state = this.missionState().survivors[0]?.state ?? 'idle';
|
const state = this.missionState().survivors[0]?.state ?? 'idle';
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
:host { display: block; }
|
:host { display: block; }
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 290px;
|
||||||
|
background: var(--fog-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 290px;
|
|
||||||
height: 56px;
|
height: 56px;
|
||||||
background: var(--fog-bg);
|
|
||||||
padding: 0 12px 0 8px;
|
padding: 0 12px 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,3 +96,33 @@
|
|||||||
color: var(--fog-text-dim);
|
color: var(--fog-text-dim);
|
||||||
font-style: italic;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,33 +1,49 @@
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="lantern-wrap">
|
<div class="main-row">
|
||||||
<button
|
<div class="lantern-wrap">
|
||||||
class="lantern"
|
<button
|
||||||
[style.border-color]="stateMeta().color"
|
class="lantern"
|
||||||
(click)="lanternClick.emit()"
|
[style.border-color]="stateMeta().color"
|
||||||
[attr.aria-label]="'Open details for ' + (missionState().survivors[0]?.name ?? 'survivor')"
|
(click)="lanternClick.emit()"
|
||||||
>
|
[attr.aria-label]="'Open details for ' + (missionState().survivors[0]?.name ?? 'survivor')"
|
||||||
@if (avatarSrc() && !imgError()) {
|
>
|
||||||
<img
|
@if (avatarSrc() && !imgError()) {
|
||||||
class="avatar-img"
|
<img
|
||||||
[src]="avatarSrc()!"
|
class="avatar-img"
|
||||||
[alt]="missionState().survivors[0]?.name ?? ''"
|
[src]="avatarSrc()!"
|
||||||
(error)="imgError.set(true)"
|
[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 {
|
} @else {
|
||||||
<span class="avatar-initials">{{ initials() }}</span>
|
<span class="log-line idle">The fog stirs…</span>
|
||||||
}
|
}
|
||||||
</button>
|
</div>
|
||||||
<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>
|
||||||
|
|
||||||
|
@if (killerName(); as kn) {
|
||||||
|
<div class="killer-strip">
|
||||||
|
@if (killerAvatarSrc() && !killerImgError()) {
|
||||||
|
<img
|
||||||
|
class="killer-thumb"
|
||||||
|
[src]="killerAvatarSrc()!"
|
||||||
|
[alt]="kn"
|
||||||
|
(error)="killerImgError.set(true)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<span class="killer-label">trial against {{ kn }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
||||||
|
import { killerAvatarUrl } from '@fog-explorer/encounter-library';
|
||||||
import { avatarUrl, survivorInitials } from './survivor-initials';
|
import { avatarUrl, survivorInitials } from './survivor-initials';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,6 +50,15 @@ export class MinimisedPanelComponent {
|
|||||||
|
|
||||||
protected readonly imgError = signal(false);
|
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(() => {
|
protected latestLogLine = computed(() => {
|
||||||
const log = this.missionState().recentLog;
|
const log = this.missionState().recentLog;
|
||||||
return log.length ? log[0].logText : null;
|
return log.length ? log[0].logText : null;
|
||||||
|
|||||||
@@ -149,15 +149,15 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--fog-amber-dim);
|
border: 1px solid var(--fog-amber-dim);
|
||||||
color: var(--fog-text-dim);
|
color: var(--fog-text);
|
||||||
font-family: var(--font-mono);
|
font-family: Verdana, Helvetica, Arial, sans-serif;
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
.character-filter::placeholder {
|
.character-filter::placeholder {
|
||||||
color: var(--fog-text-faint);
|
color: var(--fog-text-dim);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
.character-filter:focus {
|
.character-filter:focus {
|
||||||
@@ -178,9 +178,9 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-left: 2px solid transparent;
|
border-left: 2px solid transparent;
|
||||||
color: var(--fog-text-faint);
|
color: var(--fog-text-dim);
|
||||||
font-family: var(--font-mono);
|
font-family: Verdana, Helvetica, Arial, sans-serif;
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -207,8 +207,8 @@
|
|||||||
}
|
}
|
||||||
.character-btn:hover,
|
.character-btn:hover,
|
||||||
.character-btn:focus-visible {
|
.character-btn:focus-visible {
|
||||||
color: var(--fog-text-dim);
|
color: var(--fog-text);
|
||||||
border-left-color: var(--fog-amber-dim);
|
border-left-color: var(--fog-amber);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.character-btn--random {
|
.character-btn--random {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const MissionSchema = z.object({
|
|||||||
difficulty: z.number().int().min(1).max(3),
|
difficulty: z.number().int().min(1).max(3),
|
||||||
durationMinutes: MissionDurationMinutesSchema.default(20),
|
durationMinutes: MissionDurationMinutesSchema.default(20),
|
||||||
status: MissionStateSchema,
|
status: MissionStateSchema,
|
||||||
|
killerName: z.string().optional(),
|
||||||
encounterLibraryVersion: z.string().min(1),
|
encounterLibraryVersion: z.string().min(1),
|
||||||
nextTickAt: z.iso.datetime(),
|
nextTickAt: z.iso.datetime(),
|
||||||
tickIndex: z.number().int().min(0),
|
tickIndex: z.number().int().min(0),
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './lib/encounter-library';
|
export * from './lib/encounter-library';
|
||||||
export { SURVIVOR_NAMES } from './lib/survivors';
|
export { SURVIVOR_NAMES } from './lib/survivors';
|
||||||
|
export { KILLERS, KILLER_NAMES, killerAvatarUrl } from './lib/killers';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { EncounterDefinition } from '@fog-explorer/api-interfaces';
|
import type { EncounterDefinition } from '@fog-explorer/api-interfaces';
|
||||||
import { ALLY_FIRST_NAMES } from './survivors';
|
import { ALLY_FIRST_NAMES } from './survivors';
|
||||||
|
import { KILLER_NAMES } from './killers';
|
||||||
import encountersData from './encounters.json';
|
import encountersData from './encounters.json';
|
||||||
|
|
||||||
interface RawEncounter {
|
interface RawEncounter {
|
||||||
@@ -23,6 +24,7 @@ export interface LibraryEncounter extends EncounterDefinition {
|
|||||||
|
|
||||||
export interface FlavorContext {
|
export interface FlavorContext {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
killerName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIBRARY_VERSION: string = encountersData.version;
|
const LIBRARY_VERSION: string = encountersData.version;
|
||||||
@@ -66,11 +68,15 @@ export function pickFlavor(
|
|||||||
): string {
|
): string {
|
||||||
const pool = ctx.success ? encounter.flavorSuccess : encounter.flavorFailure;
|
const pool = ctx.success ? encounter.flavorSuccess : encounter.flavorFailure;
|
||||||
const raw = pool[Math.floor(rng() * pool.length)];
|
const raw = pool[Math.floor(rng() * pool.length)];
|
||||||
return expandAlly(raw, rng);
|
return expandTokens(raw, ctx, rng);
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandAlly(text: string, rng: () => number): string {
|
function expandTokens(text: string, ctx: FlavorContext, rng: () => number): string {
|
||||||
return text.replace(/\{\{ally\}\}/g, () => {
|
let out = text.replace(/\{\{ally\}\}/g, () => {
|
||||||
return ALLY_FIRST_NAMES[Math.floor(rng() * ALLY_FIRST_NAMES.length)];
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.2.0",
|
"version": "1.4.0",
|
||||||
"encounters": [
|
"encounters": [
|
||||||
{
|
{
|
||||||
"key": "generator_repair",
|
"key": "generator_repair",
|
||||||
@@ -37,26 +37,26 @@
|
|||||||
"tags": ["totem", "altruistic", "objectives"],
|
"tags": ["totem", "altruistic", "objectives"],
|
||||||
"tier": 1,
|
"tier": 1,
|
||||||
"flavorSuccess": [
|
"flavorSuccess": [
|
||||||
"It breaks. Something screams — not here, but somewhere.",
|
"A hex totem. I hear it before I see it. I break it anyway.",
|
||||||
"The bones scatter. I feel the curse lift.",
|
"Just a dull totem. I cleanse it before it can become something worse.",
|
||||||
"One hex gone. The fog feels slightly less wrong.",
|
"The hex shatters. Whatever was draining us stops.",
|
||||||
"It comes apart like it was never solid.",
|
"No curse on this one. Still worth clearing.",
|
||||||
"I don't understand what I just broke. I'm glad I broke it.",
|
"I don't know which hex it was. It's gone now. That's enough.",
|
||||||
"{{ally}} spots it first. I make short work of it.",
|
"{{ally}} spots the hex. I make short work of it.",
|
||||||
"The skull gives. Whatever watched through it is gone.",
|
"The glow fades. The hex is gone.",
|
||||||
"{{ally}} watches my back. The cleanse is fast.",
|
"Dull totem. Cleansed. One less place for a hex to root itself.",
|
||||||
"It was waiting to fall. I let it.",
|
"{{ally}} calls it out — just a dull totem, but now it's ash.",
|
||||||
"One less curse."
|
"One less totem in the trial."
|
||||||
],
|
],
|
||||||
"flavorFailure": [
|
"flavorFailure": [
|
||||||
"It pulls back. I can't finish this.",
|
"The hex totem pulls back. I can't finish this.",
|
||||||
"Something drives me away before it's done.",
|
"I reach it but something drives me off before it breaks.",
|
||||||
"The hex holds. The air gets thicker.",
|
"The hex holds. The pressure doesn't lift.",
|
||||||
"I reach for it and stop. Something warns me off.",
|
"Just a dull totem, but I can't stay long enough to cleanse it.",
|
||||||
"The totem hums. I'm not strong enough. Not yet.",
|
"The hex hums. I'm not strong enough. Not yet.",
|
||||||
"{{ally}} calls wrong. The totem burns brighter.",
|
"{{ally}} calls wrong. The hex burns brighter.",
|
||||||
"The bones won't break. Wrong approach.",
|
"No curse here, but no time either. I leave it standing.",
|
||||||
"{{ally}} needs me. I leave the totem standing.",
|
"{{ally}} needs me. The totem stays.",
|
||||||
"Halfway through. Then something changes. I run.",
|
"Halfway through. Then something changes. I run.",
|
||||||
"The hex endures. I'll find another way."
|
"The hex endures. I'll find another way."
|
||||||
]
|
]
|
||||||
@@ -160,25 +160,25 @@
|
|||||||
"I don't breathe. I don't think. It passes.",
|
"I don't breathe. I don't think. It passes.",
|
||||||
"A long silence — then the footsteps recede.",
|
"A long silence — then the footsteps recede.",
|
||||||
"I melt into the fog. Unseen.",
|
"I melt into the fog. Unseen.",
|
||||||
"Predictable. Predictable is avoidable.",
|
"{{killer}} is predictable. Predictable is avoidable.",
|
||||||
"Close. Too close. But not enough.",
|
"Close. Too close. But not enough.",
|
||||||
"{{ally}} draws the patrol the other way. I slip through.",
|
"{{ally}} draws the patrol the other way. I slip through.",
|
||||||
"A gap in the route. I take it.",
|
"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.",
|
"Footsteps near. Then far. Then gone.",
|
||||||
"The locker is cold and tight and it works."
|
"The locker is cold and tight and it works."
|
||||||
],
|
],
|
||||||
"flavorFailure": [
|
"flavorFailure": [
|
||||||
"A wrong step. Eye contact. The chase starts.",
|
"A wrong step. Eye contact. {{killer}} gives chase.",
|
||||||
"I misjudge the angle. Spotted.",
|
"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.",
|
"The route changed. I didn't know.",
|
||||||
"No cover. Nowhere to go.",
|
"No cover. Nowhere to go.",
|
||||||
"{{ally}} breaks stealth nearby. We're both compromised.",
|
"{{ally}} breaks stealth nearby. We're both compromised.",
|
||||||
"Every instinct says run. That instinct is wrong. I run anyway.",
|
"Every instinct says run. That instinct is wrong. I run anyway.",
|
||||||
"The fog doesn't protect me here.",
|
"The fog doesn't protect me here.",
|
||||||
"Spotted. Everything changes.",
|
"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",
|
"key": "pallet_drop",
|
||||||
"baseProbability": 0.55,
|
"baseProbability": 0.55,
|
||||||
"tags": ["survival", "escape"],
|
"tags": ["survival", "chase"],
|
||||||
"tier": 2,
|
"tier": 2,
|
||||||
"flavorSuccess": [
|
"flavorSuccess": [
|
||||||
"It crashes down between us. A moment bought.",
|
"{{killer}} is right behind me. I drop the pallet at the right instant. Distance bought.",
|
||||||
"Wood and impact. I gain distance.",
|
"Mid-chase. The wood crashes down between us. I gain ground.",
|
||||||
"The drop lands right. The chase falters.",
|
"I run the loop and hit the pallet at the last second. {{killer}} staggers.",
|
||||||
"Timed right. The pallet works.",
|
"Timed right. The pallet interrupts the chase.",
|
||||||
"They stagger. I use every second.",
|
"{{killer}} tries to mindgame the drop. I read it. The pallet lands.",
|
||||||
"The drop lands at the right instant.",
|
"The chase breaks. The pallet did its job.",
|
||||||
"A barrier placed. The chase interrupted.",
|
"A roar of frustration behind me. I'm already running.",
|
||||||
"It holds. That's all it needs to do.",
|
"The gap widens. I don't look back.",
|
||||||
"A roar behind the pallet. I'm already running.",
|
"{{killer}} can't close it. The pallet holds them.",
|
||||||
"The gap widens. Use it."
|
"I break the chase. For now."
|
||||||
],
|
],
|
||||||
"flavorFailure": [
|
"flavorFailure": [
|
||||||
"The pallet drops wide. No gap.",
|
"{{killer}} predicts the drop. I waste the pallet.",
|
||||||
"Too slow. It means nothing.",
|
"Too slow — {{killer}} is through before the wood lands.",
|
||||||
"I miscalculate. The chase continues.",
|
"I panic mid-chase. The pallet falls early. Useless.",
|
||||||
"Too early. Wasted.",
|
"{{killer}} vaults before the drop. The chase continues.",
|
||||||
"They vault before the wood lands.",
|
"Wrong angle. The pallet saves nothing.",
|
||||||
"Wrong angle. Wrong moment.",
|
"The chase doesn't stop. I'm out of pallets.",
|
||||||
"The swing comes first.",
|
"The swing comes before the wood hits the ground.",
|
||||||
"Not enough distance. Never was.",
|
"I misjudge the distance. Wasted.",
|
||||||
"I panic. The pallet follows.",
|
"One less pallet. The chase goes on.",
|
||||||
"Wasted. The chase doesn't stop."
|
"The drop panics. So do I."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -304,31 +304,31 @@
|
|||||||
{
|
{
|
||||||
"key": "window_vault",
|
"key": "window_vault",
|
||||||
"baseProbability": 0.60,
|
"baseProbability": 0.60,
|
||||||
"tags": ["escape", "survival"],
|
"tags": ["survival", "chase"],
|
||||||
"tier": 1,
|
"tier": 1,
|
||||||
"flavorSuccess": [
|
"flavorSuccess": [
|
||||||
"Clean vault. The frame holds.",
|
"{{killer}} is closing in. I hit the window before they grab me.",
|
||||||
"Through and clear. Momentum kept.",
|
"Clean vault. I reset the chase distance.",
|
||||||
"Tight, but usable.",
|
"In a chase and the window is right there. I take it.",
|
||||||
"I make it through before they close the gap.",
|
"I make it through before {{killer}} can follow cleanly.",
|
||||||
"The vault buys distance. I use it.",
|
"The vault buys a second. In a chase, that's everything.",
|
||||||
"Practiced. The window yields.",
|
"Practiced. Through before {{killer}} can react.",
|
||||||
"I land clean on the other side.",
|
"I land clean. The chase tilts my way.",
|
||||||
"The window is a door when I need it.",
|
"The window resets the loop. I use it.",
|
||||||
"Fast enough. The gap opens.",
|
"Fast vault. The gap opens.",
|
||||||
"Through. The chase resets."
|
"I break the chase line. Through and running."
|
||||||
],
|
],
|
||||||
"flavorFailure": [
|
"flavorFailure": [
|
||||||
"Boarded. No exit here.",
|
"Boarded. I lose the chase line.",
|
||||||
"Too slow. I'm caught mid-vault.",
|
"Too slow. {{killer}} catches me mid-vault.",
|
||||||
"The frame splinters wrong.",
|
"I misjudge the height. Mid-chase, that's fatal.",
|
||||||
"I misjudge the height. I pay for it.",
|
"The window doesn't save me here.",
|
||||||
"The window doesn't solve the problem.",
|
"Too narrow. Wrong call in a chase.",
|
||||||
"Too narrow.",
|
"The vault fails at the worst moment.",
|
||||||
"The worst possible moment for this to fail.",
|
"{{killer}} reads the vault. I gain nothing.",
|
||||||
"The opening was an illusion.",
|
"Bad angle. The chase continues on their terms.",
|
||||||
"Bad angle. Bad outcome.",
|
"Blocked. I'm out of options on this loop.",
|
||||||
"The window doesn't help tonight."
|
"The window doesn't help. The chase ends badly."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -398,27 +398,27 @@
|
|||||||
"tier": 2,
|
"tier": 2,
|
||||||
"flavorSuccess": [
|
"flavorSuccess": [
|
||||||
"{{ally}} gets there first. Both of us run.",
|
"{{ally}} gets there first. Both of us run.",
|
||||||
"Clean unhook. Nobody watching.",
|
"Clean unhook. {{killer}} not watching.",
|
||||||
"I get them down. The trial continues.",
|
"I get them down. The trial continues.",
|
||||||
"Fast hands, right timing.",
|
"Fast hands, right timing.",
|
||||||
"They're off the hook. We move.",
|
"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 rescue works. Don't stop moving.",
|
||||||
"The window was there. I took it.",
|
"The window was there. I took it.",
|
||||||
"{{ally}} gets them clear. Two of us running again.",
|
"{{ally}} gets them clear. Two of us running again.",
|
||||||
"Free. Both running now."
|
"Free. Both running now."
|
||||||
],
|
],
|
||||||
"flavorFailure": [
|
"flavorFailure": [
|
||||||
"{{ally}} pulls back. The killer turns too soon.",
|
"{{ally}} pulls back. {{killer}} turns too soon.",
|
||||||
"The rescue fails. Both of us in danger.",
|
"The rescue fails. Both of us in danger.",
|
||||||
"Wrong timing.",
|
"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.",
|
"No safe window. I leave.",
|
||||||
"The hook holds.",
|
"The hook holds.",
|
||||||
"{{ally}}'s approach is spotted.",
|
"{{ally}}'s approach is spotted.",
|
||||||
"They're hooked again. Worse now.",
|
"They're hooked again. Worse now.",
|
||||||
"The rescue was a trap.",
|
"The rescue was a trap.",
|
||||||
"The killer was closer than it looked."
|
"{{killer}} was closer than it looked."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -578,27 +578,87 @@
|
|||||||
"tier": 3,
|
"tier": 3,
|
||||||
"flavorSuccess": [
|
"flavorSuccess": [
|
||||||
"The instinct misfires. I'm not found.",
|
"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.",
|
"The prediction fails. I'm safe.",
|
||||||
"They check elsewhere.",
|
"{{killer}} checks elsewhere.",
|
||||||
"Wrong corner. I breathe.",
|
"Wrong corner. I breathe.",
|
||||||
"They're certain. They're wrong.",
|
"{{killer}} is certain. {{killer}} is wrong.",
|
||||||
"The read was off. I move.",
|
"The read was off. I move.",
|
||||||
"Not there. Not tonight.",
|
"Not there. Not tonight.",
|
||||||
"The instinct fails them.",
|
"{{killer}}'s instinct fails them.",
|
||||||
"Their focus breaks. I use it."
|
"{{killer}}'s focus breaks. I use it."
|
||||||
],
|
],
|
||||||
"flavorFailure": [
|
"flavorFailure": [
|
||||||
"They know exactly where I am.",
|
"{{killer}} knows exactly where I am.",
|
||||||
"Instinct and experience. I'm found.",
|
"Instinct and experience. I'm found.",
|
||||||
"There's no hiding from something this certain.",
|
"There's no hiding from {{killer}}.",
|
||||||
"The read was right.",
|
"The read was right.",
|
||||||
"My position is given away before the search starts.",
|
"My position is given away before the search starts.",
|
||||||
"Exactly where expected.",
|
"Exactly where expected.",
|
||||||
"Their certainty was earned.",
|
"{{killer}}'s certainty was earned.",
|
||||||
"Found before the search begins.",
|
"Found before the search begins.",
|
||||||
"The instinct is right.",
|
"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."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
55
libs/encounter-library/src/lib/killers.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"outDir": "../../dist/out-tsc",
|
"outDir": "../../dist/out-tsc",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
|
|||||||