Files
fog/libs/mission-logic/src/lib/encounter-resolver.ts

157 lines
4.1 KiB
TypeScript
Raw Normal View History

import seedrandom = require('seedrandom');
import type {
EncounterDefinition,
EncounterResult,
ModifierApplication,
Perk,
SurvivorState,
SurvivorStats,
} from '@fog-explorer/api-interfaces';
export interface ResolverInput {
seed: string;
missionId: string;
tickIndex: number;
difficulty: number;
encounter: EncounterDefinition;
survivor: {
id: string;
state: SurvivorState;
stats: SurvivorStats;
perkSlots: Perk[];
hookCount: number;
};
}
// Probability adjustments per stat point and per difficulty level.
const OBJECTIVES_BONUS = 0.02;
const ALTRUISM_BONUS = 0.02;
const DIFFICULTY_PENALTY = 0.12;
// Tag that enables the altruism stat bonus.
const ALTRUISM_TAG = 'altruistic';
// Injury probability floor/ceiling when active survivor fails.
const INJURY_CHANCE_BASE = 0.8;
const INJURY_CHANCE_FLOOR = 0.2;
const SURVIVAL_INJURY_REDUCTION = 0.05;
export function resolveEncounter(input: ResolverInput): EncounterResult {
const rng = seedrandom(input.seed);
let probability = input.encounter.baseProbability;
probability -= (input.difficulty - 1) * DIFFICULTY_PENALTY;
probability += input.survivor.stats.objectives * OBJECTIVES_BONUS;
if (input.encounter.tags.includes(ALTRUISM_TAG)) {
probability += input.survivor.stats.altruism * ALTRUISM_BONUS;
}
const modifiersApplied = applyPerkModifiers(probability, input);
probability = modifiersApplied.adjusted;
const appliedList = modifiersApplied.applied;
probability = Math.max(0, Math.min(1, probability));
const success = rng() < probability;
const survivorStateChange = success
? null
: computeStateChange(rng, input.survivor.state, input.survivor.stats.survival, input.survivor.hookCount);
return {
missionId: input.missionId,
survivorId: input.survivor.id,
encounterKey: input.encounter.key,
tickIndex: input.tickIndex,
seed: input.seed,
success,
survivorStateChange,
modifiersApplied: appliedList,
logText: buildLogText(input.encounter.key, success, survivorStateChange),
};
}
function applyPerkModifiers(
startProbability: number,
input: ResolverInput
): { adjusted: number; applied: ModifierApplication[] } {
let probability = startProbability;
const applied: ModifierApplication[] = [];
for (const perk of input.survivor.perkSlots) {
for (const mod of perk.modifiers) {
if (mod.target !== 'successChance') continue;
if (mod.condition) {
const tagMatch = mod.condition.encounterTags.some((t) =>
input.encounter.tags.includes(t)
);
if (!tagMatch) continue;
}
if (mod.type === 'additive') {
probability += mod.amount;
} else {
probability *= 1 + mod.amount;
}
applied.push({
perkKey: perk.key,
target: mod.target,
type: mod.type,
effectiveAmount: mod.amount,
});
}
}
return { adjusted: probability, applied };
}
function computeStateChange(
rng: seedrandom.PRNG,
currentState: SurvivorState,
survivalStat: number,
hookCount: number
): EncounterResult['survivorStateChange'] {
if (currentState === 'active') {
const injuryChance = Math.max(
INJURY_CHANCE_FLOOR,
INJURY_CHANCE_BASE - survivalStat * SURVIVAL_INJURY_REDUCTION
);
if (rng() < injuryChance) {
return { from: 'active', to: 'injured' };
}
return null;
}
if (currentState === 'injured') {
return { from: 'injured', to: 'downed' };
}
if (currentState === 'downed' && hookCount >= 2) {
return { from: 'downed', to: 'sacrificed' };
}
return null;
}
const STATE_CHANGE_TEXT: Record<string, string> = {
'active->injured': 'Survivor was injured.',
'injured->downed': 'Survivor was downed.',
'downed->sacrificed': 'Survivor was sacrificed.',
};
function buildLogText(
encounterKey: string,
success: boolean,
stateChange: EncounterResult['survivorStateChange']
): string {
const outcome = success ? 'succeeded' : 'failed';
const base = `${encounterKey}: ${outcome}.`;
if (!stateChange) return base;
const extra = STATE_CHANGE_TEXT[`${stateChange.from}->${stateChange.to}`];
return extra ? `${base} ${extra}` : base;
}