first commit

This commit is contained in:
Hussar
2026-04-12 15:35:50 +00:00
commit 42d20cb0ed
80 changed files with 2210 additions and 0 deletions

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