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 => 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 = {}): fc.Arbitrary => 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([]), hookCount: fc.integer({ min: 0, max: 2 }), }), }).map((base) => ({ ...base, ...overrides })); // ── Helpers ─────────────────────────────────────────────────────────────────── function makeInput(partial: Partial = {}): 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); } }); }); // ── 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 = 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): 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 55–75%', () => { 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 30–55%', () => { 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); }); });