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:
Maurycy
2026-05-07 14:25:46 +00:00
parent 65af268b86
commit e8523d270e
66 changed files with 4074 additions and 72 deletions

View File

@@ -11,6 +11,7 @@ export default [
ignoredFiles: [
'{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
'{projectRoot}/vite.config.{js,ts,mjs,mts}',
'{projectRoot}/vitest.config.{js,ts,mjs,mts}',
],
},
],

View File

@@ -7,6 +7,10 @@
"types": "./src/index.d.ts",
"dependencies": {
"tslib": "^2.3.0",
"seedrandom": "^3.0.5",
"@fog-explorer/api-interfaces": "*"
},
"devDependencies": {
"vitest": "^4.0.8",
"@nx/vite": "^22.7.1"
}

View File

@@ -1 +1 @@
export * from './lib/mission-logic';
export * from './lib/encounter-resolver';

View 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 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);
});
});

View File

@@ -0,0 +1,156 @@
import seedrandom = require('seedrandom');
import type {
EncounterDefinition,
EncounterResult,
ModifierApplication,
Perk,
SurvivorState,
SurvivorStats,
} from '@fog-explorer/api-interfaces';
export interface ResolverInput {
seed: string;
missionId: string;
tickIndex: number;
difficulty: number;
encounter: EncounterDefinition;
survivor: {
id: string;
state: SurvivorState;
stats: SurvivorStats;
perkSlots: Perk[];
hookCount: number;
};
}
// Probability adjustments per stat point and per difficulty level.
const OBJECTIVES_BONUS = 0.02;
const ALTRUISM_BONUS = 0.02;
const DIFFICULTY_PENALTY = 0.12;
// Tag that enables the altruism stat bonus.
const ALTRUISM_TAG = 'altruistic';
// Injury probability floor/ceiling when active survivor fails.
const INJURY_CHANCE_BASE = 0.8;
const INJURY_CHANCE_FLOOR = 0.2;
const SURVIVAL_INJURY_REDUCTION = 0.05;
export function resolveEncounter(input: ResolverInput): EncounterResult {
const rng = seedrandom(input.seed);
let probability = input.encounter.baseProbability;
probability -= (input.difficulty - 1) * DIFFICULTY_PENALTY;
probability += input.survivor.stats.objectives * OBJECTIVES_BONUS;
if (input.encounter.tags.includes(ALTRUISM_TAG)) {
probability += input.survivor.stats.altruism * ALTRUISM_BONUS;
}
const modifiersApplied = applyPerkModifiers(probability, input);
probability = modifiersApplied.adjusted;
const appliedList = modifiersApplied.applied;
probability = Math.max(0, Math.min(1, probability));
const success = rng() < probability;
const survivorStateChange = success
? null
: computeStateChange(rng, input.survivor.state, input.survivor.stats.survival, input.survivor.hookCount);
return {
missionId: input.missionId,
survivorId: input.survivor.id,
encounterKey: input.encounter.key,
tickIndex: input.tickIndex,
seed: input.seed,
success,
survivorStateChange,
modifiersApplied: appliedList,
logText: buildLogText(input.encounter.key, success, survivorStateChange),
};
}
function applyPerkModifiers(
startProbability: number,
input: ResolverInput
): { adjusted: number; applied: ModifierApplication[] } {
let probability = startProbability;
const applied: ModifierApplication[] = [];
for (const perk of input.survivor.perkSlots) {
for (const mod of perk.modifiers) {
if (mod.target !== 'successChance') continue;
if (mod.condition) {
const tagMatch = mod.condition.encounterTags.some((t) =>
input.encounter.tags.includes(t)
);
if (!tagMatch) continue;
}
if (mod.type === 'additive') {
probability += mod.amount;
} else {
probability *= 1 + mod.amount;
}
applied.push({
perkKey: perk.key,
target: mod.target,
type: mod.type,
effectiveAmount: mod.amount,
});
}
}
return { adjusted: probability, applied };
}
function computeStateChange(
rng: seedrandom.PRNG,
currentState: SurvivorState,
survivalStat: number,
hookCount: number
): EncounterResult['survivorStateChange'] {
if (currentState === 'active') {
const injuryChance = Math.max(
INJURY_CHANCE_FLOOR,
INJURY_CHANCE_BASE - survivalStat * SURVIVAL_INJURY_REDUCTION
);
if (rng() < injuryChance) {
return { from: 'active', to: 'injured' };
}
return null;
}
if (currentState === 'injured') {
return { from: 'injured', to: 'downed' };
}
if (currentState === 'downed' && hookCount >= 2) {
return { from: 'downed', to: 'sacrificed' };
}
return null;
}
const STATE_CHANGE_TEXT: Record<string, string> = {
'active->injured': 'Survivor was injured.',
'injured->downed': 'Survivor was downed.',
'downed->sacrificed': 'Survivor was sacrificed.',
};
function buildLogText(
encounterKey: string,
success: boolean,
stateChange: EncounterResult['survivorStateChange']
): string {
const outcome = success ? 'succeeded' : 'failed';
const base = `${encounterKey}: ${outcome}.`;
if (!stateChange) return base;
const extra = STATE_CHANGE_TEXT[`${stateChange.from}->${stateChange.to}`];
return extra ? `${base} ${extra}` : base;
}

View File

@@ -1,7 +0,0 @@
import { missionLogic } from './mission-logic';
describe('missionLogic', () => {
it('should work', () => {
expect(missionLogic()).toEqual('mission-logic');
});
});

View File

@@ -1,3 +0,0 @@
export function missionLogic(): string {
return 'mission-logic';
}