Stage 5: Add Prisma integration and enhance mission management

- Introduced Prisma as a dependency in package.json and updated pnpm-lock.yaml.
- Created Prisma module and service for database interactions.
- Added initial Prisma schema and migration for user, survivor, mission, and related entities.
- Implemented throttling in the API using @nestjs/throttler for rate limiting.
- Enhanced mission management logic to utilize Prisma for database transactions.
- Updated missions controller and service to handle mission state and participant management.
- Added Twitch PubSub service for real-time updates on mission states.
This commit is contained in:
Maurycy
2026-05-07 15:42:52 +00:00
parent e8523d270e
commit 21f1a5319f
22 changed files with 1676 additions and 96 deletions

View File

@@ -1 +1,2 @@
export * from './lib/encounter-resolver';
export * from './lib/perk-math';

View File

@@ -198,6 +198,69 @@ describe('resolveEncounter', () => {
expect(result.logText.length).toBeGreaterThan(0);
}
});
// Edge cases: modifier overflow / degenerate inputs
it('massive positive perk modifier clamps probability to 1 — never throws', () => {
const perk: Perk = {
id: '00000000-0000-4000-a000-000000000003',
key: 'overpowered',
name: '',
description: '',
tags: [],
modifiers: [{ target: 'successChance', type: 'additive', amount: 100 }],
};
const result = resolveEncounter(
makeInput({ encounter: { key: 'gen', baseProbability: 0.5, tags: [] },
survivor: { id: '00000000-0000-4000-a000-000000000002', state: 'active',
stats: { objectives: 5, survival: 5, altruism: 5 }, perkSlots: [perk], hookCount: 0 } })
);
expect(result.success).toBe(true); // clamped to 1 → always succeeds
});
it('massive negative perk modifier clamps probability to 0 — never throws', () => {
const perk: Perk = {
id: '00000000-0000-4000-a000-000000000003',
key: 'cursed',
name: '',
description: '',
tags: [],
modifiers: [{ target: 'successChance', type: 'additive', amount: -100 }],
};
const result = resolveEncounter(
makeInput({ encounter: { key: 'gen', baseProbability: 0.5, tags: [] },
survivor: { id: '00000000-0000-4000-a000-000000000002', state: 'active',
stats: { objectives: 5, survival: 5, altruism: 5 }, perkSlots: [perk], hookCount: 0 } })
);
expect(result.success).toBe(false); // clamped to 0 → always fails
});
it('empty perkSlots array is handled without error', () => {
expect(() =>
resolveEncounter(makeInput({ survivor: {
id: '00000000-0000-4000-a000-000000000002', state: 'active',
stats: { objectives: 5, survival: 5, altruism: 5 }, perkSlots: [], hookCount: 0,
} }))
).not.toThrow();
});
it('sacrificed survivor produces no state change on failure', () => {
const result = resolveEncounter(makeInput({
encounter: { key: 'gen', baseProbability: 0, tags: [] },
survivor: { id: '00000000-0000-4000-a000-000000000002', state: 'sacrificed',
stats: { objectives: 1, survival: 1, altruism: 1 }, perkSlots: [], hookCount: 2 },
}));
expect(result.success).toBe(false);
expect(result.survivorStateChange).toBeNull();
});
it('idle survivor produces no state change on failure', () => {
const result = resolveEncounter(makeInput({
encounter: { key: 'gen', baseProbability: 0, tags: [] },
survivor: { id: '00000000-0000-4000-a000-000000000002', state: 'idle',
stats: { objectives: 1, survival: 1, altruism: 1 }, perkSlots: [], hookCount: 0 },
}));
expect(result.survivorStateChange).toBeNull();
});
});
// ── Property-based tests ──────────────────────────────────────────────────────

View File

@@ -2,11 +2,11 @@ import seedrandom = require('seedrandom');
import type {
EncounterDefinition,
EncounterResult,
ModifierApplication,
Perk,
SurvivorState,
SurvivorStats,
} from '@fog-explorer/api-interfaces';
import { applyPerkModifiers } from './perk-math';
export interface ResolverInput {
seed: string;
@@ -23,15 +23,10 @@ export interface ResolverInput {
};
}
// 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;
@@ -40,25 +35,28 @@ 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;
const { probability: finalProbability, applied } = applyPerkModifiers(
probability,
input.survivor.perkSlots,
input.encounter.tags
);
probability = Math.max(0, Math.min(1, probability));
const success = rng() < probability;
const success = rng() < finalProbability;
const survivorStateChange = success
? null
: computeStateChange(rng, input.survivor.state, input.survivor.stats.survival, input.survivor.hookCount);
: computeStateChange(
rng,
input.survivor.state,
input.survivor.stats.survival,
input.survivor.hookCount
);
return {
missionId: input.missionId,
@@ -68,47 +66,11 @@ export function resolveEncounter(input: ResolverInput): EncounterResult {
seed: input.seed,
success,
survivorStateChange,
modifiersApplied: appliedList,
modifiersApplied: applied,
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,

View File

@@ -0,0 +1,305 @@
import { resolveEncounter } from './encounter-resolver';
import type {
EncounterDefinition,
Perk,
SurvivorState,
SurvivorStats,
} from '@fog-explorer/api-interfaces';
// ── Constants ─────────────────────────────────────────────────────────────────
const TICKS_PER_MISSION = 20;
const MISSIONS_PER_SCENARIO = 2000; // 5 scenarios × 2000 = 10 000 total
// ── Types ─────────────────────────────────────────────────────────────────────
interface SimScenario {
readonly name: string;
readonly difficulty: number;
readonly encounter: EncounterDefinition;
readonly stats: SurvivorStats;
readonly perks: Perk[];
}
interface MissionOutcome {
readonly successCount: number;
readonly wasInjured: boolean;
readonly wasSacrificed: boolean;
}
interface DistributionSnapshot {
successRate: number;
injuryRate: number;
sacrificeRate: number;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function makePerk(
key: string,
amount: number,
type: 'additive' | 'multiplicative'
): Perk {
return {
id: '00000000-0000-4000-a000-000000000001',
key,
name: key,
description: '',
tags: [],
modifiers: [{ target: 'successChance', type, amount }],
};
}
function simulateMission(
scenario: SimScenario,
missionIndex: number
): MissionOutcome {
let state: SurvivorState = 'active';
let hookCount = 0;
let successCount = 0;
let wasInjured = false;
let wasSacrificed = false;
for (let tick = 0; tick < TICKS_PER_MISSION; tick++) {
const result = resolveEncounter({
seed: `mc-${scenario.name}-${missionIndex}-${tick}`,
missionId: `mc-mission-${missionIndex}`,
tickIndex: tick,
difficulty: scenario.difficulty,
encounter: scenario.encounter,
survivor: {
id: '00000000-0000-4000-a000-000000000002',
state,
stats: scenario.stats,
perkSlots: scenario.perks,
hookCount,
},
});
if (result.survivorStateChange) {
const sc = result.survivorStateChange;
if (sc.to === 'injured') {
state = 'injured';
wasInjured = true;
} else if (sc.to === 'downed') {
// Stay downed for the next tick so downed+hookCount≥2 → sacrifice can fire.
hookCount++;
state = 'downed';
wasInjured = true;
} else if (sc.to === 'sacrificed') {
wasSacrificed = true;
break;
}
} else {
// No state change: success, or failure with no consequence (e.g. downed+hookCount<2).
// If currently downed (and not sacrificed), the encounter resolves without harm — rescued.
if (state === 'downed') {
state = 'active';
}
if (result.success) {
successCount++;
}
}
}
return { successCount, wasInjured, wasSacrificed };
}
function runScenario(scenario: SimScenario): DistributionSnapshot {
let totalSuccess = 0;
let injuredCount = 0;
let sacrificedCount = 0;
for (let i = 0; i < MISSIONS_PER_SCENARIO; i++) {
const { successCount, wasInjured, wasSacrificed } = simulateMission(
scenario,
i
);
totalSuccess += successCount;
if (wasInjured) injuredCount++;
if (wasSacrificed) sacrificedCount++;
}
const round = (n: number): number => Math.round(n * 1000) / 1000;
return {
successRate: round(totalSuccess / (MISSIONS_PER_SCENARIO * TICKS_PER_MISSION)),
injuryRate: round(injuredCount / MISSIONS_PER_SCENARIO),
sacrificeRate: round(sacrificedCount / MISSIONS_PER_SCENARIO),
};
}
// ── Scenarios ─────────────────────────────────────────────────────────────────
const STANDARD_ENCOUNTER: EncounterDefinition = {
key: 'generator',
baseProbability: 0.6,
tags: [],
};
const BASE_STATS: SurvivorStats = { objectives: 5, survival: 5, altruism: 5 };
const SCENARIOS = {
baseline: {
name: 'baseline',
difficulty: 1,
encounter: STANDARD_ENCOUNTER,
stats: BASE_STATS,
perks: [],
},
hard: {
name: 'hard',
difficulty: 3,
encounter: STANDARD_ENCOUNTER,
stats: BASE_STATS,
perks: [],
},
objectivesPerk: {
name: 'objectives-perk',
difficulty: 1,
encounter: STANDARD_ENCOUNTER,
stats: BASE_STATS,
perks: [makePerk('objectives-boost', 0.15, 'additive')],
},
survivalPerk: {
name: 'survival-perk',
difficulty: 1,
encounter: STANDARD_ENCOUNTER,
stats: { ...BASE_STATS, survival: 10 },
perks: [],
},
stacked: {
// objectives=7 → +0.14, add +0.05, mul ×1.10 → ~87% final probability.
// Represents a well-optimised build that is strong but not degenerate.
name: 'stacked',
difficulty: 1,
encounter: STANDARD_ENCOUNTER,
stats: { objectives: 7, survival: 7, altruism: 5 },
perks: [
makePerk('add-boost', 0.05, 'additive'),
makePerk('mul-boost', 0.1, 'multiplicative'),
],
},
} satisfies Record<string, SimScenario>;
// ── Distribution snapshot tests ───────────────────────────────────────────────
// Seeds are deterministic strings → results are reproducible across runs.
// On first run, vitest writes the inline snapshots; CI then guards regressions.
describe('Monte Carlo balance harness — distribution snapshots', () => {
let results: Record<keyof typeof SCENARIOS, DistributionSnapshot>;
beforeAll(() => {
results = {
baseline: runScenario(SCENARIOS.baseline),
hard: runScenario(SCENARIOS.hard),
objectivesPerk: runScenario(SCENARIOS.objectivesPerk),
survivalPerk: runScenario(SCENARIOS.survivalPerk),
stacked: runScenario(SCENARIOS.stacked),
};
});
it('baseline distribution', () => {
expect(results.baseline).toMatchInlineSnapshot(`
{
"injuryRate": 0.973,
"sacrificeRate": 0.194,
"successRate": 0.67,
}
`);
});
it('hard difficulty distribution', () => {
expect(results.hard).toMatchInlineSnapshot(`
{
"injuryRate": 0.999,
"sacrificeRate": 0.72,
"successRate": 0.341,
}
`);
});
it('objectives-perk distribution', () => {
expect(results.objectivesPerk).toMatchInlineSnapshot(`
{
"injuryRate": 0.815,
"sacrificeRate": 0.018,
"successRate": 0.849,
}
`);
});
it('survival-perk distribution', () => {
expect(results.survivalPerk).toMatchInlineSnapshot(`
{
"injuryRate": 0.846,
"sacrificeRate": 0.093,
"successRate": 0.686,
}
`);
});
it('stacked perk loadout distribution', () => {
expect(results.stacked).toMatchInlineSnapshot(`
{
"injuryRate": 0.711,
"sacrificeRate": 0.007,
"successRate": 0.866,
}
`);
});
});
// ── Balance envelope assertions ───────────────────────────────────────────────
// These fire on top of snapshots to catch degenerate configurations even if
// a snapshot was accidentally committed with broken numbers.
describe('Monte Carlo balance harness — balance envelopes', () => {
let results: Record<keyof typeof SCENARIOS, DistributionSnapshot>;
beforeAll(() => {
results = {
baseline: runScenario(SCENARIOS.baseline),
hard: runScenario(SCENARIOS.hard),
objectivesPerk: runScenario(SCENARIOS.objectivesPerk),
survivalPerk: runScenario(SCENARIOS.survivalPerk),
stacked: runScenario(SCENARIOS.stacked),
};
});
it('hard difficulty has lower success rate than baseline', () => {
expect(results.hard.successRate).toBeLessThan(results.baseline.successRate);
});
it('hard difficulty has higher sacrifice rate than baseline', () => {
expect(results.hard.sacrificeRate).toBeGreaterThan(
results.baseline.sacrificeRate
);
});
it('objectives perk increases success rate over baseline', () => {
expect(results.objectivesPerk.successRate).toBeGreaterThan(
results.baseline.successRate
);
});
it('stacked loadout does not produce degenerate success rate (>0.95)', () => {
expect(results.stacked.successRate).toBeLessThan(0.95);
});
it('baseline sacrifice rate is meaningful (>1%) and not excessive (<25%)', () => {
expect(results.baseline.sacrificeRate).toBeGreaterThan(0.01);
expect(results.baseline.sacrificeRate).toBeLessThan(0.25);
});
it('every scenario produces some successes (not locked out)', () => {
for (const dist of Object.values(results)) {
expect(dist.successRate).toBeGreaterThan(0);
}
});
it('every scenario has a chance of injury (game has stakes)', () => {
for (const dist of Object.values(results)) {
expect(dist.injuryRate).toBeGreaterThan(0);
}
});
});

View File

@@ -0,0 +1,225 @@
import * as fc from 'fast-check';
import { applyPerkModifiers } from './perk-math';
import type { Perk } from '@fog-explorer/api-interfaces';
// ── Helpers ───────────────────────────────────────────────────────────────────
function makePerk(overrides: Partial<Perk> & { amount: number; type?: 'additive' | 'multiplicative'; tags?: string[] }): Perk {
return {
id: '00000000-0000-4000-a000-000000000001',
key: overrides.key ?? 'test-perk',
name: 'Test Perk',
description: '',
tags: [],
modifiers: [
{
target: 'successChance',
type: overrides.type ?? 'additive',
amount: overrides.amount,
condition: overrides.tags ? { encounterTags: overrides.tags } : undefined,
},
],
};
}
// ── Unit tests ────────────────────────────────────────────────────────────────
describe('applyPerkModifiers', () => {
it('returns baseProbability unchanged with no perks', () => {
const { probability, applied } = applyPerkModifiers(0.5, [], []);
expect(probability).toBe(0.5);
expect(applied).toHaveLength(0);
});
it('applies a single additive modifier', () => {
const { probability } = applyPerkModifiers(0.4, [makePerk({ amount: 0.2 })], []);
expect(probability).toBeCloseTo(0.6);
});
it('applies a single multiplicative modifier', () => {
const { probability } = applyPerkModifiers(0.5, [makePerk({ amount: 0.2, type: 'multiplicative' })], []);
expect(probability).toBeCloseTo(0.6);
});
it('additives sum before multiplicatives apply', () => {
const perks: Perk[] = [
makePerk({ key: 'a', amount: 0.1 }), // additive +0.1
makePerk({ key: 'b', amount: 0.1 }), // additive +0.1
makePerk({ key: 'c', amount: 0.5, type: 'multiplicative' }), // ×1.5
];
// base 0.4 + 0.2 additive = 0.6 → × 1.5 = 0.9
const { probability } = applyPerkModifiers(0.4, perks, []);
expect(probability).toBeCloseTo(0.9);
});
it('clamps overflow above 1.0 to 1.0', () => {
const perks = [makePerk({ amount: 5.0 })]; // base 0.5 + 5 = 5.5 → clamped
const { probability } = applyPerkModifiers(0.5, perks, []);
expect(probability).toBe(1);
});
it('clamps underflow below 0 to 0', () => {
const perks = [makePerk({ amount: -5.0 })];
const { probability } = applyPerkModifiers(0.5, perks, []);
expect(probability).toBe(0);
});
it('negative multiplicative modifier reduces probability', () => {
const { probability } = applyPerkModifiers(0.6, [makePerk({ amount: -0.5, type: 'multiplicative' })], []);
expect(probability).toBeCloseTo(0.3);
});
it('skips modifier when condition tag not in encounter tags', () => {
const perk = makePerk({ amount: 0.3, tags: ['totem'] });
const { probability, applied } = applyPerkModifiers(0.5, [perk], ['generator']);
expect(probability).toBeCloseTo(0.5);
expect(applied).toHaveLength(0);
});
it('applies modifier when condition tag matches', () => {
const perk = makePerk({ amount: 0.3, tags: ['generator'] });
const { probability } = applyPerkModifiers(0.5, [perk], ['generator', 'objectives']);
expect(probability).toBeCloseTo(0.8);
});
it('unconditional modifier applies regardless of encounter tags', () => {
const perk = makePerk({ amount: 0.2 }); // no condition
const { probability } = applyPerkModifiers(0.5, [perk], []);
expect(probability).toBeCloseTo(0.7);
});
it('non-successChance target modifiers are ignored', () => {
const perk: Perk = {
id: '00000000-0000-4000-a000-000000000001',
key: 'stat-perk',
name: '',
description: '',
tags: [],
modifiers: [{ target: 'objectives', type: 'additive', amount: 2 }],
};
const { probability } = applyPerkModifiers(0.5, [perk], []);
expect(probability).toBe(0.5);
});
it('records one applied entry per modifier', () => {
const perks = [
makePerk({ key: 'a', amount: 0.1 }),
makePerk({ key: 'b', amount: 0.2 }),
];
const { applied } = applyPerkModifiers(0.4, perks, []);
expect(applied).toHaveLength(2);
expect(applied.map((a) => a.perkKey)).toEqual(expect.arrayContaining(['a', 'b']));
});
it('applied entries record preApply and postApply probabilities', () => {
const perk = makePerk({ amount: 0.2 });
const { applied } = applyPerkModifiers(0.4, [perk], []);
expect(applied[0].preApplyProbability).toBeCloseTo(0.4);
expect(applied[0].postApplyProbability).toBeCloseTo(0.6);
});
});
// ── Property-based tests ──────────────────────────────────────────────────────
describe('applyPerkModifiers — property-based', () => {
const arbAmount = fc.float({ min: Math.fround(-1), max: Math.fround(1), noNaN: true });
const arbPerk: fc.Arbitrary<Perk> = fc.record({
id: fc.uuid(),
key: fc.string({ minLength: 1, maxLength: 20 }),
name: fc.string({ minLength: 1 }),
description: fc.string(),
tags: fc.constant([]),
modifiers: fc.array(
fc.record({
target: fc.constantFrom('successChance' as const),
type: fc.constantFrom('additive' as const, 'multiplicative' as const),
amount: arbAmount,
condition: fc.option(
fc.record({ encounterTags: fc.array(fc.constantFrom('generator', 'totem'), { minLength: 1 }) }),
{ nil: undefined }
),
}),
{ maxLength: 3 }
),
});
it('result probability is always in [0, 1]', () => {
fc.assert(
fc.property(
fc.float({ min: 0, max: 1, noNaN: true }),
fc.array(arbPerk, { maxLength: 4 }),
fc.array(fc.constantFrom('generator', 'totem', 'exit'), { maxLength: 3 }),
(base, perks, tags) => {
const { probability } = applyPerkModifiers(base, perks, tags);
expect(probability).toBeGreaterThanOrEqual(0);
expect(probability).toBeLessThanOrEqual(1);
}
)
);
});
it('every applied modifier references a perk in the input', () => {
fc.assert(
fc.property(
fc.float({ min: 0, max: 1, noNaN: true }),
fc.array(arbPerk, { maxLength: 4 }),
(base, perks) => {
const { applied } = applyPerkModifiers(base, perks, []);
const keys = new Set(perks.map((p) => p.key));
for (const mod of applied) {
expect(keys.has(mod.perkKey)).toBe(true);
}
}
)
);
});
it('no perks never changes base probability (within [0,1])', () => {
fc.assert(
fc.property(fc.float({ min: 0, max: 1, noNaN: true }), (base) => {
const { probability, applied } = applyPerkModifiers(base, [], []);
expect(probability).toBeCloseTo(base);
expect(applied).toHaveLength(0);
})
);
});
it('additive modifiers with large positive amounts still clamp to 1', () => {
fc.assert(
fc.property(
fc.float({ min: 0, max: 1, noNaN: true }),
fc.array(
fc.float({ min: Math.fround(0.1), max: Math.fround(10), noNaN: true }),
{ minLength: 1, maxLength: 5 }
),
(base, amounts) => {
const perks = amounts.map((amount, i) =>
makePerk({ key: `p${i}`, amount })
);
const { probability } = applyPerkModifiers(base, perks, []);
expect(probability).toBeLessThanOrEqual(1);
}
)
);
});
it('additive modifiers with large negative amounts still clamp to 0', () => {
fc.assert(
fc.property(
fc.float({ min: 0, max: 1, noNaN: true }),
fc.array(
fc.float({ min: Math.fround(-10), max: Math.fround(-0.1), noNaN: true }),
{ minLength: 1, maxLength: 5 }
),
(base, amounts) => {
const perks = amounts.map((amount, i) =>
makePerk({ key: `p${i}`, amount })
);
const { probability } = applyPerkModifiers(base, perks, []);
expect(probability).toBeGreaterThanOrEqual(0);
}
)
);
});
});

View File

@@ -0,0 +1,101 @@
import type { ModifierApplication, Perk, PerkModifierTarget } from '@fog-explorer/api-interfaces';
export interface AppliedModifier extends ModifierApplication {
readonly preApplyProbability: number;
readonly postApplyProbability: number;
}
export interface PerkMathResult {
readonly probability: number;
readonly applied: AppliedModifier[];
}
const P_MIN = 0;
const P_MAX = 1;
/**
* Applies all perk modifiers targeting `successChance` from the given perk
* slots, filtered by encounter tags. Additives are summed first, then
* multiplicatives applied in order. Result is clamped to [0, 1].
*
* Separating this from the resolver keeps the probability pipeline testable
* independently and makes modifier-overflow edge cases visible.
*/
export function applyPerkModifiers(
baseProbability: number,
perks: Perk[],
encounterTags: string[],
target: PerkModifierTarget = 'successChance'
): PerkMathResult {
let probability = baseProbability;
const applied: AppliedModifier[] = [];
// Two-pass: collect additive sum, then apply multiplicatives.
// This matches standard RPG convention: add-then-multiply prevents
// multiplicative stacking from compounding excessively.
let additiveSum = 0;
const multiplicativePerks: Array<{ perk: Perk; amount: number }> = [];
for (const perk of perks) {
for (const mod of perk.modifiers) {
if (mod.target !== target) continue;
if (!conditionMatches(mod.condition, encounterTags)) continue;
if (mod.type === 'additive') {
additiveSum += mod.amount;
} else {
multiplicativePerks.push({ perk, amount: mod.amount });
}
}
}
// Apply additive sum in one step.
if (additiveSum !== 0) {
const pre = probability;
probability += additiveSum;
// Record one entry per contributing perk.
for (const perk of perks) {
for (const mod of perk.modifiers) {
if (mod.target !== target || mod.type !== 'additive') continue;
if (!conditionMatches(mod.condition, encounterTags)) continue;
applied.push({
perkKey: perk.key,
target: mod.target,
type: mod.type,
effectiveAmount: mod.amount,
preApplyProbability: pre,
postApplyProbability: probability,
});
}
}
}
// Apply multiplicatives individually so each can be logged.
for (const { perk, amount } of multiplicativePerks) {
const pre = probability;
probability *= 1 + amount;
applied.push({
perkKey: perk.key,
target,
type: 'multiplicative',
effectiveAmount: amount,
preApplyProbability: pre,
postApplyProbability: probability,
});
}
return { probability: clamp(probability, P_MIN, P_MAX), applied };
}
function conditionMatches(
condition: { encounterTags: string[] } | undefined,
encounterTags: string[]
): boolean {
if (!condition) return true;
return condition.encounterTags.some((t) => encounterTags.includes(t));
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}