- 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.
463 lines
17 KiB
TypeScript
463 lines
17 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);
|
||
}
|
||
});
|
||
|
||
// 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 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);
|
||
});
|
||
});
|