157 lines
4.1 KiB
TypeScript
157 lines
4.1 KiB
TypeScript
|
|
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;
|
||
|
|
}
|