first commit

This commit is contained in:
Hussar
2026-04-12 16:43:45 +01:00
commit 9213df4828
79 changed files with 2204 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
{
"name": "api-interfaces",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api-interfaces/src",
"projectType": "library",
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/api-interfaces",
"main": "libs/api-interfaces/src/index.ts",
"tsConfig": "libs/api-interfaces/tsconfig.lib.json"
}
}
},
"tags": ["scope:shared"]
}

View File

@@ -0,0 +1,5 @@
export * from './lib/survivor';
export * from './lib/mission';
export * from './lib/perk';
export * from './lib/encounter';
export * from './lib/channel-config';

View File

@@ -0,0 +1,8 @@
import { MissionDifficulty } from './mission';
export interface ChannelConfig {
channelId: string;
difficultyPreset: MissionDifficulty;
maxPartySize: number;
featureFlags: Record<string, boolean>;
}

View File

@@ -0,0 +1,19 @@
import { MissionDifficulty, MissionState } from './mission';
import { SurvivorStats } from './survivor';
export interface EncounterResult {
outcome: 'success' | 'injury' | 'sacrifice';
text: string;
successChance: number;
roll: number;
nextState: MissionState;
nextStats: SurvivorStats;
}
export interface ResolveEncounterInput {
stats: SurvivorStats;
difficulty: MissionDifficulty;
perkIds: string[];
tickIndex: number;
seed?: number;
}

View File

@@ -0,0 +1,35 @@
import { SurvivorStats } from './survivor';
export enum MissionState {
Lobby = 'Lobby',
InProgress = 'InProgress',
Completed = 'Completed',
Failed = 'Failed'
}
export enum MissionDifficulty {
Easy = 'Easy',
Normal = 'Normal',
Hard = 'Hard',
Nightmare = 'Nightmare'
}
export interface EncounterLogLine {
sequence: number;
event: string;
text: string;
successChance: number;
roll: number;
}
export interface MissionSnapshot {
id: string;
channelId: string;
survivorId: string;
state: MissionState;
difficulty: MissionDifficulty;
tickIndex: number;
recentLog: EncounterLogLine[];
stats: SurvivorStats;
perkIds: string[];
}

View File

@@ -0,0 +1,19 @@
export enum ModifierKind {
Additive = 'additive',
Multiplicative = 'multiplicative',
FlatReroll = 'flat_reroll'
}
export interface PerkModifier {
id: string;
kind: ModifierKind;
value: number;
teamPerk?: boolean;
}
export interface Perk {
id: string;
name: string;
description: string;
modifiers: PerkModifier[];
}

View File

@@ -0,0 +1,18 @@
export enum SurvivorState {
Active = 'Active',
Injured = 'Injured',
Sacrificed = 'Sacrificed'
}
export interface SurvivorStats {
health: number;
stealth: number;
teamwork: number;
luck: number;
}
export interface Survivor {
id: string;
state: SurvivorState;
stats: SurvivorStats;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"types": []
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,24 @@
{
"name": "encounter-library",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/encounter-library/src",
"projectType": "library",
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/encounter-library",
"main": "libs/encounter-library/src/index.ts",
"tsConfig": "libs/encounter-library/tsconfig.lib.json"
}
},
"validate": {
"executor": "@nx/workspace:run-commands",
"options": {
"command": "ts-node libs/encounter-library/src/lib/validate-encounters.ts"
}
}
},
"tags": ["scope:content"]
}

View File

@@ -0,0 +1 @@
export * from './lib/encounter-library';

View File

@@ -0,0 +1,42 @@
import encountersData from './encounters.json';
export interface EncounterRecord {
id: string;
tier: string;
baseSuccessChance: number;
difficultyTags: string[];
perkTags: string[];
flavor: string[];
}
interface EncounterDocument {
schemaVersion: number;
records: EncounterRecord[];
}
const doc = encountersData as EncounterDocument;
export function listEncounters(): EncounterRecord[] {
return doc.records;
}
export function getEncounterById(id: string): EncounterRecord | undefined {
return doc.records.find((record) => record.id === id);
}
export function getRandomEncounterByTier(tier: string, seed = Date.now()): EncounterRecord {
const matching = doc.records.filter((record) => record.tier === tier);
const pool = matching.length > 0 ? matching : doc.records;
const index = Math.abs(seed) % pool.length;
return pool[index];
}
export function migrateEncounterDocument(document: EncounterDocument): EncounterDocument {
if (document.schemaVersion === 1) {
return document;
}
return {
schemaVersion: 1,
records: document.records
};
}

View File

@@ -0,0 +1,45 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["schemaVersion", "records"],
"properties": {
"schemaVersion": {
"type": "integer",
"minimum": 1
},
"records": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"tier",
"baseSuccessChance",
"difficultyTags",
"perkTags",
"flavor"
],
"properties": {
"id": { "type": "string", "minLength": 1 },
"tier": { "type": "string" },
"baseSuccessChance": { "type": "number", "minimum": 0, "maximum": 1 },
"difficultyTags": {
"type": "array",
"items": { "type": "string" }
},
"perkTags": {
"type": "array",
"items": { "type": "string" }
},
"flavor": {
"type": "array",
"items": { "type": "string" },
"minItems": 1
}
},
"additionalProperties": false
}
}
},
"additionalProperties": false
}

View File

@@ -0,0 +1,27 @@
{
"schemaVersion": 1,
"records": [
{
"id": "cleanse_hex",
"tier": "common",
"baseSuccessChance": 0.62,
"difficultyTags": ["normal", "hard"],
"perkTags": ["totem", "support"],
"flavor": [
"The bones glow with cursed light.",
"You cleanse the totem before the killer returns."
]
},
{
"id": "escape_hatch",
"tier": "rare",
"baseSuccessChance": 0.41,
"difficultyTags": ["hard", "nightmare"],
"perkTags": ["mobility", "luck"],
"flavor": [
"A rusted hatch creaks in the distance.",
"One wrong step and the fog closes in."
]
}
]
}

View File

@@ -0,0 +1,27 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { z } from 'zod';
const encounterRecord = z.object({
id: z.string().min(1),
tier: z.string(),
baseSuccessChance: z.number().min(0).max(1),
difficultyTags: z.array(z.string()),
perkTags: z.array(z.string()),
flavor: z.array(z.string()).min(1)
});
const encounterDocument = z.object({
schemaVersion: z.number().int().min(1),
records: z.array(encounterRecord)
});
function main(): void {
const file = resolve(process.cwd(), 'libs/encounter-library/src/lib/encounters.json');
const raw = readFileSync(file, 'utf-8');
const parsed = JSON.parse(raw) as unknown;
encounterDocument.parse(parsed);
process.stdout.write('Encounter library validation passed.\n');
}
main();

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts", "src/**/*.json"]
}

View File

@@ -0,0 +1,24 @@
{
"name": "mission-logic",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/mission-logic/src",
"projectType": "library",
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/mission-logic",
"main": "libs/mission-logic/src/index.ts",
"tsConfig": "libs/mission-logic/tsconfig.lib.json"
}
},
"simulate": {
"executor": "@nx/workspace:run-commands",
"options": {
"command": "ts-node libs/mission-logic/src/simulator/cli.ts"
}
}
},
"tags": ["scope:logic"]
}

View File

@@ -0,0 +1,3 @@
export * from './lib/encounter-resolver';
export * from './lib/perk-math';
export * from './lib/group-synergy.service';

View File

@@ -0,0 +1,74 @@
import {
EncounterResult,
MissionDifficulty,
MissionState,
ResolveEncounterInput,
SurvivorStats
} from '@fog-explorer/api-interfaces';
import { applyPerkModifiers, clamp } from './perk-math';
const DIFFICULTY_BASE: Record<MissionDifficulty, number> = {
[MissionDifficulty.Easy]: 0.72,
[MissionDifficulty.Normal]: 0.6,
[MissionDifficulty.Hard]: 0.48,
[MissionDifficulty.Nightmare]: 0.38
};
export function resolveEncounter(input: ResolveEncounterInput): EncounterResult {
const baseChance = DIFFICULTY_BASE[input.difficulty];
const perkBoost = input.perkIds.length * 0.015;
const statBoost =
input.stats.stealth * 0.002 + input.stats.teamwork * 0.002 + input.stats.luck * 0.002;
const successChance = clamp(
applyPerkModifiers(baseChance + perkBoost + statBoost, []),
0.05,
0.95
);
const roll = seededRoll(input.seed ?? input.tickIndex);
if (roll <= successChance) {
return {
outcome: 'success',
text: 'The survivor outplayed the fog and advanced.',
successChance,
roll,
nextState: MissionState.InProgress,
nextStats: { ...input.stats, health: Math.min(100, input.stats.health + 1) }
};
}
const injuryThreshold = successChance + 0.25;
if (roll <= injuryThreshold) {
const injured = applyDamage(input.stats, 14);
return {
outcome: 'injury',
text: 'A close call leaves the survivor injured.',
successChance,
roll,
nextState: injured.health <= 0 ? MissionState.Failed : MissionState.InProgress,
nextStats: injured
};
}
return {
outcome: 'sacrifice',
text: 'The fog claims another soul.',
successChance,
roll,
nextState: MissionState.Failed,
nextStats: { ...input.stats, health: 0 }
};
}
function applyDamage(stats: SurvivorStats, amount: number): SurvivorStats {
return {
...stats,
health: Math.max(0, stats.health - amount)
};
}
function seededRoll(seed: number): number {
const normalized = Math.abs(Math.sin(seed * 99991)) * 10000;
return normalized - Math.floor(normalized);
}

View File

@@ -0,0 +1,15 @@
import { PerkModifier } from '@fog-explorer/api-interfaces';
export interface GroupContext {
memberCount: number;
teamModifiers: PerkModifier[];
}
export class GroupSynergyService {
buildContext(teamModifiers: PerkModifier[]): GroupContext {
return {
memberCount: Math.max(1, Math.min(4, teamModifiers.length || 1)),
teamModifiers
};
}
}

View File

@@ -0,0 +1,18 @@
import { ModifierKind, PerkModifier } from '@fog-explorer/api-interfaces';
export function applyPerkModifiers(baseChance: number, modifiers: PerkModifier[]): number {
let chance = baseChance;
for (const modifier of modifiers) {
if (modifier.kind === ModifierKind.Additive) {
chance += modifier.value;
}
if (modifier.kind === ModifierKind.Multiplicative) {
chance *= modifier.value;
}
}
return clamp(chance, 0.05, 0.95);
}
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}

View File

@@ -0,0 +1,67 @@
import { MissionDifficulty, MissionState, SurvivorStats } from '@fog-explorer/api-interfaces';
import { resolveEncounter } from '../lib/encounter-resolver';
function parseArg(name: string, fallback: string): string {
const key = `--${name}=`;
const arg = process.argv.find((x) => x.startsWith(key));
return arg ? arg.slice(key.length) : fallback;
}
function main(): void {
const runs = Number(parseArg('runs', '10000'));
const seed = Number(parseArg('seed', '42'));
const difficulty = parseArg('difficulty', MissionDifficulty.Normal) as MissionDifficulty;
let successes = 0;
let injuries = 0;
let sacrifices = 0;
const lengths: number[] = [];
for (let run = 0; run < runs; run += 1) {
let state = MissionState.InProgress;
let tick = 0;
let stats: SurvivorStats = { health: 100, stealth: 10, teamwork: 10, luck: 10 };
while (state === MissionState.InProgress && tick < 40) {
tick += 1;
const result = resolveEncounter({
stats,
difficulty,
perkIds: [],
tickIndex: tick,
seed: seed + run * 101 + tick
});
if (result.outcome === 'success') successes += 1;
if (result.outcome === 'injury') injuries += 1;
if (result.outcome === 'sacrifice') sacrifices += 1;
state = result.nextState;
stats = result.nextStats;
}
lengths.push(tick);
}
lengths.sort((a, b) => a - b);
const p50 = lengths[Math.floor(lengths.length * 0.5)];
const p90 = lengths[Math.floor(lengths.length * 0.9)];
const p99 = lengths[Math.floor(lengths.length * 0.99)];
const totalEvents = successes + injuries + sacrifices;
const pct = (n: number) => ((n / totalEvents) * 100).toFixed(2);
console.log(
JSON.stringify(
{
runs,
difficulty,
successRatePct: pct(successes),
injuryRatePct: pct(injuries),
sacrificeRatePct: pct(sacrifices),
missionLengthPercentiles: { p50, p90, p99 }
},
null,
2
)
);
}
main();

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"rootDir": "../../",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}