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:
@@ -1 +1,2 @@
|
||||
export * from './lib/encounter-resolver';
|
||||
export * from './lib/perk-math';
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
305
libs/mission-logic/src/lib/monte-carlo.spec.ts
Normal file
305
libs/mission-logic/src/lib/monte-carlo.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
225
libs/mission-logic/src/lib/perk-math.spec.ts
Normal file
225
libs/mission-logic/src/lib/perk-math.spec.ts
Normal 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);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
101
libs/mission-logic/src/lib/perk-math.ts
Normal file
101
libs/mission-logic/src/lib/perk-math.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user