400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
|
|
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);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ── 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 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);
|
|||
|
|
});
|
|||
|
|
});
|