Refactor API and enhance Angular integration
- Removed `ciTargetName` from `nx.json`. - Updated `package.json` to include new dependencies: `@types/seedrandom`, `fast-check`, `happy-dom`, and `@nestjs/schedule`. - Modified `pnpm-lock.yaml` to reflect the addition of new packages and their versions. - Improved project documentation in `PROJECT_CONTEXT.md` to clarify the use of Zod schemas and Angular framework decisions. - Introduced new Angular components and patterns in the `.agents/skills/frontend-angular` directory, including examples and reference materials for Angular 21+ features.
This commit is contained in:
399
libs/mission-logic/src/lib/encounter-resolver.spec.ts
Normal file
399
libs/mission-logic/src/lib/encounter-resolver.spec.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user