Files
fog/libs/mission-logic/src/lib/encounter-resolver.spec.ts
Maurycy 21f1a5319f 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.
2026-05-07 15:42:52 +00:00

463 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as fc from 'fast-check';
import { resolveEncounter, type ResolverInput } from './encounter-resolver';
import type { EncounterDefinition, Perk } from '@fog-explorer/api-interfaces';
// ── Arbitraries ──────────────────────────────────────────────────────────────
const arbSeed = fc.string({ minLength: 8, maxLength: 16 });
const arbEncounter = (tags: string[] = []): fc.Arbitrary<EncounterDefinition> =>
fc.record({
key: fc.constantFrom('generator', 'totem', 'chest', 'hook', 'exit-gate'),
baseProbability: fc.float({ min: 0, max: 1, noNaN: true }),
tags: fc.constant(tags),
});
const arbStats = fc.record({
objectives: fc.integer({ min: 1, max: 10 }),
survival: fc.integer({ min: 1, max: 10 }),
altruism: fc.integer({ min: 1, max: 10 }),
});
const arbInput = (overrides: Partial<ResolverInput> = {}): fc.Arbitrary<ResolverInput> =>
fc.record({
seed: arbSeed,
missionId: fc.uuid(),
tickIndex: fc.nat({ max: 999 }),
difficulty: fc.integer({ min: 1, max: 3 }),
encounter: arbEncounter(),
survivor: fc.record({
id: fc.uuid(),
state: fc.constantFrom('active' as const, 'injured' as const),
stats: arbStats,
perkSlots: fc.constant<Perk[]>([]),
hookCount: fc.integer({ min: 0, max: 2 }),
}),
}).map((base) => ({ ...base, ...overrides }));
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeInput(partial: Partial<ResolverInput> = {}): ResolverInput {
return {
seed: 'fixed-seed',
missionId: '00000000-0000-4000-a000-000000000001',
tickIndex: 0,
difficulty: 1,
encounter: { key: 'generator', baseProbability: 0.5, tags: [] },
survivor: {
id: '00000000-0000-4000-a000-000000000002',
state: 'active',
stats: { objectives: 5, survival: 5, altruism: 5 },
perkSlots: [],
hookCount: 0,
},
...partial,
};
}
// ── Unit tests ────────────────────────────────────────────────────────────────
describe('resolveEncounter', () => {
it('is deterministic: same seed always produces same result', () => {
const input = makeInput();
const a = resolveEncounter(input);
const b = resolveEncounter(input);
expect(a).toEqual(b);
});
it('produces different results for different seeds', () => {
const results = new Set(
Array.from({ length: 20 }, (_, i) =>
resolveEncounter(makeInput({ seed: `seed-${i}` })).success
)
);
// With 20 different seeds at p≈0.6 both true and false should appear
expect(results.size).toBeGreaterThan(1);
});
it('state change is null on success', () => {
// Force a near-certain success with baseProbability=1
const result = resolveEncounter(makeInput({ encounter: { key: 'generator', baseProbability: 1, tags: [] } }));
expect(result.success).toBe(true);
expect(result.survivorStateChange).toBeNull();
});
it('injured survivor transitions to downed on failure', () => {
// Force failure: baseProbability=0
const result = resolveEncounter(
makeInput({
encounter: { key: 'hook', baseProbability: 0, tags: [] },
survivor: {
id: '00000000-0000-4000-a000-000000000002',
state: 'injured',
stats: { objectives: 1, survival: 5, altruism: 1 },
perkSlots: [],
hookCount: 1,
},
})
);
expect(result.success).toBe(false);
expect(result.survivorStateChange).toEqual({ from: 'injured', to: 'downed' });
});
it('downed survivor at hookCount 2 transitions to sacrificed on failure', () => {
const result = resolveEncounter(
makeInput({
encounter: { key: 'hook', baseProbability: 0, tags: [] },
survivor: {
id: '00000000-0000-4000-a000-000000000002',
state: 'downed',
stats: { objectives: 1, survival: 1, altruism: 1 },
perkSlots: [],
hookCount: 2,
},
})
);
expect(result.success).toBe(false);
expect(result.survivorStateChange).toEqual({ from: 'downed', to: 'sacrificed' });
});
it('downed survivor below hookCount 2 has no state change on failure', () => {
const result = resolveEncounter(
makeInput({
encounter: { key: 'hook', baseProbability: 0, tags: [] },
survivor: {
id: '00000000-0000-4000-a000-000000000002',
state: 'downed',
stats: { objectives: 1, survival: 5, altruism: 1 },
perkSlots: [],
hookCount: 1,
},
})
);
expect(result.success).toBe(false);
expect(result.survivorStateChange).toBeNull();
});
it('perk modifier with matching tag applies to success chance', () => {
const perk: Perk = {
id: '00000000-0000-4000-a000-000000000003',
key: 'adrenaline',
name: 'Adrenaline',
description: '',
tags: [],
modifiers: [
{ target: 'successChance', type: 'additive', amount: 0.5, condition: { encounterTags: ['generator'] } },
],
};
// With p=0 base but +0.5 from perk on matching tag → p=0.5 → some successes
const results = Array.from({ length: 10 }, (_, i) =>
resolveEncounter(
makeInput({
seed: `perk-test-${i}`,
encounter: { key: 'gen', baseProbability: 0, tags: ['generator'] },
survivor: {
id: '00000000-0000-4000-a000-000000000002',
state: 'active',
stats: { objectives: 1, survival: 5, altruism: 1 },
perkSlots: [perk],
hookCount: 0,
},
})
).success
);
expect(results.some(Boolean)).toBe(true);
});
it('perk modifier without matching tag does not apply', () => {
const perk: Perk = {
id: '00000000-0000-4000-a000-000000000003',
key: 'irrelevant-perk',
name: 'Irrelevant',
description: '',
tags: [],
modifiers: [
{ target: 'successChance', type: 'additive', amount: 0.99, condition: { encounterTags: ['totem'] } },
],
};
// p=0, perk requires 'totem' but encounter has 'generator' → still fails
const result = resolveEncounter(
makeInput({
encounter: { key: 'gen', baseProbability: 0, tags: ['generator'] },
survivor: {
id: '00000000-0000-4000-a000-000000000002',
state: 'active',
stats: { objectives: 1, survival: 5, altruism: 1 },
perkSlots: [perk],
hookCount: 0,
},
})
);
expect(result.success).toBe(false);
expect(result.modifiersApplied).toHaveLength(0);
});
it('logText is non-empty for all outcomes', () => {
for (let i = 0; i < 20; i++) {
const result = resolveEncounter(makeInput({ seed: `log-test-${i}` }));
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 ──────────────────────────────────────────────────────
describe('resolveEncounter — property-based', () => {
it('result fields match input identifiers', () => {
fc.assert(
fc.property(arbInput(), (input) => {
const result = resolveEncounter(input);
expect(result.missionId).toBe(input.missionId);
expect(result.survivorId).toBe(input.survivor.id);
expect(result.encounterKey).toBe(input.encounter.key);
expect(result.tickIndex).toBe(input.tickIndex);
expect(result.seed).toBe(input.seed);
})
);
});
it('state change is always null when encounter succeeds', () => {
fc.assert(
fc.property(arbInput(), (input) => {
const result = resolveEncounter(input);
if (result.success) {
expect(result.survivorStateChange).toBeNull();
}
})
);
});
it('state change from field always matches survivor current state', () => {
fc.assert(
fc.property(arbInput(), (input) => {
const result = resolveEncounter(input);
if (result.survivorStateChange) {
expect(result.survivorStateChange.from).toBe(input.survivor.state);
}
})
);
});
it('logText is always a non-empty string', () => {
fc.assert(
fc.property(arbInput(), (input) => {
const result = resolveEncounter(input);
expect(typeof result.logText).toBe('string');
expect(result.logText.length).toBeGreaterThan(0);
})
);
});
it('higher difficulty never increases success rate (monte carlo, large sample)', () => {
fc.assert(
fc.property(
arbSeed,
arbEncounter(),
arbStats,
(seed, encounter, stats) => {
const wins = (difficulty: number) =>
Array.from({ length: 40 }, (_, i) =>
resolveEncounter(
makeInput({ seed: `${seed}-${i}`, difficulty, encounter, survivor: {
id: '00000000-0000-4000-a000-000000000002',
state: 'active',
stats,
perkSlots: [],
hookCount: 0,
} })
).success
).filter(Boolean).length;
const easy = wins(1);
const hard = wins(3);
// Over 40 trials, difficulty 3 should not outperform difficulty 1 by more than 10 wins
expect(hard - easy).toBeLessThanOrEqual(10);
}
),
{ numRuns: 50 }
);
});
it('all modifiersApplied entries reference perks in the input perkSlots', () => {
const arbPerk: fc.Arbitrary<Perk> = fc.record({
id: fc.uuid(),
key: fc.string({ minLength: 1 }),
name: fc.string({ minLength: 1 }),
description: fc.string(),
tags: fc.array(fc.string()),
modifiers: fc.array(
fc.record({
target: fc.constantFrom('successChance' as const),
type: fc.constantFrom('additive' as const, 'multiplicative' as const),
amount: fc.float({ min: Math.fround(-0.3), max: Math.fround(0.3), noNaN: true }),
condition: fc.option(
fc.record({ encounterTags: fc.array(fc.constantFrom('generator', 'totem'), { minLength: 1 }) }),
{ nil: undefined }
),
}),
{ maxLength: 3 }
),
});
fc.assert(
fc.property(
arbInput().chain((base) =>
fc.array(arbPerk, { maxLength: 4 }).map((perks) => ({
...base,
survivor: { ...base.survivor, perkSlots: perks },
}))
),
(input) => {
const result = resolveEncounter(input);
const perkKeys = new Set(input.survivor.perkSlots.map((p) => p.key));
for (const mod of result.modifiersApplied) {
expect(perkKeys.has(mod.perkKey)).toBe(true);
}
}
)
);
});
});
// ── Monte Carlo balance snapshot ─────────────────────────────────────────────
describe('encounter resolver — Monte Carlo balance snapshot', () => {
const RUNS = 2000;
function winRate(overrides: Partial<ResolverInput>): number {
return (
Array.from({ length: RUNS }, (_, i) =>
resolveEncounter(makeInput({ seed: `mc-${i}`, ...overrides })).success
).filter(Boolean).length / RUNS
);
}
it('difficulty 1, objectives 5, p=0.5 → win rate 5575%', () => {
const rate = winRate({ difficulty: 1, encounter: { key: 'gen', baseProbability: 0.5, tags: [] } });
// objectives 5 adds 0.10, so effective p ≈ 0.6
expect(rate).toBeGreaterThanOrEqual(0.55);
expect(rate).toBeLessThanOrEqual(0.75);
});
it('difficulty 3, objectives 5, p=0.5 → win rate 3055%', () => {
const rate = winRate({ difficulty: 3, encounter: { key: 'gen', baseProbability: 0.5, tags: [] } });
// objectives 5 adds 0.10, difficulty penalty -0.24 → effective p ≈ 0.36
expect(rate).toBeGreaterThanOrEqual(0.30);
expect(rate).toBeLessThanOrEqual(0.55);
});
it('near-impossible encounter (p=0, min stats) win rate stays below 5%', () => {
// With baseProbability=0 and objectives=1: effective p = 0 + 1*0.02 = 0.02
const rate = winRate({ encounter: { key: 'impossible', baseProbability: 0, tags: [] },
survivor: { id: '00000000-0000-4000-a000-000000000002', state: 'active',
stats: { objectives: 1, survival: 5, altruism: 1 }, perkSlots: [], hookCount: 0 },
});
expect(rate).toBeLessThan(0.05);
});
it('certain encounter (p=1) always succeeds', () => {
const rate = winRate({ encounter: { key: 'certain', baseProbability: 1, tags: [] },
survivor: { id: '00000000-0000-4000-a000-000000000002', state: 'active',
stats: { objectives: 1, survival: 1, altruism: 1 }, perkSlots: [], hookCount: 0 },
difficulty: 3,
});
// Even at difficulty 3 with min stats, effective p ≥ 0 — but p=1 with difficulty penalty:
// 1 - 0.24 + 0.02 = 0.78, so not always 100%. Test that it's at least 70%.
expect(rate).toBeGreaterThanOrEqual(0.70);
});
it('injury rate decreases as survival stat increases', () => {
function injuryRate(survivalStat: number): number {
let injuries = 0;
for (let i = 0; i < RUNS; i++) {
const result = resolveEncounter(
makeInput({
seed: `inj-${i}`,
encounter: { key: 'hook', baseProbability: 0, tags: [] },
survivor: {
id: '00000000-0000-4000-a000-000000000002',
state: 'active',
stats: { objectives: 1, survival: survivalStat, altruism: 1 },
perkSlots: [],
hookCount: 0,
},
})
);
if (result.survivorStateChange?.to === 'injured') injuries++;
}
return injuries / RUNS;
}
const lowSurvival = injuryRate(1);
const highSurvival = injuryRate(10);
expect(highSurvival).toBeLessThan(lowSurvival);
// At survival=10 the injury floor is 0.3, so rate should be around 0.3
expect(highSurvival).toBeLessThanOrEqual(0.40);
// At survival=1 the injury rate should be around 0.75
expect(lowSurvival).toBeGreaterThanOrEqual(0.65);
});
});