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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
<div class="panel"> <div class="panel">
<div class="main-row">
<div class="lantern-wrap"> <div class="lantern-wrap">
<button <button
class="lantern" class="lantern"
@@ -30,4 +31,19 @@
<span class="log-line idle">The fog stirs…</span> <span class="log-line idle">The fog stirs…</span>
} }
</div> </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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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", "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": [