Compare commits

...

1 Commits

Author SHA1 Message Date
Maurycy
0517202412 Add killerName to Mission model and enhance overlay components
- Introduced killerName field in the Mission model within the Prisma schema and created a corresponding migration.
- Updated mission management logic to assign a killerName during mission creation.
- Enhanced overlay components to display killer information, including avatar and name, in both expanded and minimized panels.
- Added new CSS styles for killer display in the overlay.
- Included new avatar images for various killers in the overlay assets.
2026-05-11 17:39:22 +00:00
60 changed files with 372 additions and 127 deletions

View File

@@ -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.

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "missions" ADD COLUMN "killer_name" TEXT;

View File

@@ -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")

View File

@@ -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 });

View File

@@ -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)];
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -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 ── */

View File

@@ -41,6 +41,19 @@
}
</div>
<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>
</div>
}

View File

@@ -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<string, string> = {

View File

@@ -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;
}

View File

@@ -1,4 +1,5 @@
<div class="panel">
<div class="main-row">
<div class="lantern-wrap">
<button
class="lantern"
@@ -30,4 +31,19 @@
<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>

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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),

View File

@@ -1,2 +1,3 @@
export * from './lib/encounter-library';
export { SURVIVOR_NAMES } from './lib/survivors';
export { KILLERS, KILLER_NAMES, killerAvatarUrl } from './lib/killers';

View File

@@ -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;
}

View File

@@ -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."
]
}
]

View 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;
}

View File

@@ -4,7 +4,8 @@
"outDir": "../../dist/out-tsc",
"declaration": true,
"types": ["node"],
"resolveJsonModule": true
"resolveJsonModule": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts"],
"exclude": [