import { describe, it, expect, afterEach } from 'vitest'; import { createState as createIntroState, startGame, tick, buyUpgrade, type State } from './engine.ts'; function createState(): State { return startGame(createIntroState()); } import { UPGRADES } from './upgrades.ts'; // Deterministic PRNG so simulation tests aren't flaky, but still exercise // the real event-selection and random-jitter code paths. function seededRandom(seed: number): () => number { let s = seed; return () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; }; } let restoreRandom: (() => void) | null = null; function seedRandom(seed: number): void { const rnd = seededRandom(seed); const original = Math.random; Math.random = rnd; restoreRandom = () => { Math.random = original; }; } afterEach(() => { restoreRandom?.(); restoreRandom = null; }); /** Buy every upgrade whose id is in `ids`, in order, if affordable and available. */ function tryBuy(state: State, ids: string[]): State { let s = state; for (const id of ids) { s = buyUpgrade(s, id); } return s; } describe('resource invariants', () => { it('keeps hemlighet, kaos, and ritual within [0, 100] over a long random run', () => { seedRandom(42); let s = createState(); for (let i = 0; i < 30000 && s.phase === 'playing'; i++) { s = tick(s); // occasionally recruit / exert influence to exercise those paths too if (i % 500 === 0) s = tryBuy(s, ['recruit_neighbor', 'exert_influence']); expect(s.hemlighet).toBeGreaterThanOrEqual(0); expect(s.hemlighet).toBeLessThanOrEqual(100); expect(s.kaos).toBeGreaterThanOrEqual(0); expect(s.kaos).toBeLessThanOrEqual(100); expect(s.ritual).toBeGreaterThanOrEqual(0); expect(s.ritual).toBeLessThanOrEqual(100); expect(s.troende).toBeGreaterThanOrEqual(1); } }); it('never lets a kaos event push kaos negative or leave it stuck at 100', () => { seedRandom(7); let s = createState(); s.kaos = 99; for (let i = 0; i < 5000 && s.phase === 'playing'; i++) { s = tick(s); } expect(s.kaos).toBeGreaterThanOrEqual(0); }); }); describe('the ritual is winnable', () => { it('reaches phase "won" within a reasonable number of ticks under a sensible strategy', () => { seedRandom(1); let s = createState(); const storyPath = [ 'first_meeting', 'the_manuscript', 'blood_pact', 'idol_under_bridge', 'varnhem_circle', 'torso_beacon', 'grand_ritual_prep', ]; const MAX_TICKS = 400_000; // ~3.7h of real playtime at 30fps — generous upper bound for (let i = 0; i < MAX_TICKS && s.phase === 'playing'; i++) { s = tick(s); // Defend secrecy proactively every tick — reactive defense (only once // hemlighet is already low) is too slow against cascading events. if (s.hemlighet < 85) s = tryBuy(s, ['exert_influence']); if (i % 30 === 0) { s = tryBuy(s, storyPath); s = tryBuy(s, ['recruit_neighbor']); if (s.upgrades.has('grand_ritual_prep')) s = tryBuy(s, ['deepen_ritual']); } } expect(s.phase).toBe('won'); }); }); describe('neglecting secrecy is punishable', () => { it('can lose the game if hemlighet is never defended while growing the cult', () => { seedRandom(3); let s = createState(); const MAX_TICKS = 400_000; for (let i = 0; i < MAX_TICKS && s.phase === 'playing'; i++) { s = tick(s); // Grow aggressively but NEVER spend on exert_influence or story upgrades // that unlock ritual — just recruit, to stress secrecy drain. if (i % 30 === 0) s = tryBuy(s, ['recruit_neighbor']); } // A cult that only ever recruits and never manages its secrecy should // eventually be exposed, not idle forever or win by accident. expect(s.phase).toBe('lost'); }); }); describe('recruit_neighbor cost scaling', () => { it('grows monotonically with troende so andakt stays a meaningful sink', () => { const recruit = UPGRADES.find(u => u.id === 'recruit_neighbor')!; let s = createState(); let lastCost = 0; for (let i = 0; i < 15; i++) { const cost = recruit.cost(s).andakt!; expect(cost).toBeGreaterThan(lastCost); lastCost = cost; s = { ...s, troende: s.troende + 1 }; } }); }); describe('deepen_ritual cost scaling', () => { it('grows monotonically with each purchase, giving andakt a late-game sink', () => { let s = createState(); s.upgrades.add('grand_ritual_prep'); s.ritualRate = 2.5; // baseline after torso_beacon + grand_ritual_prep s.andakt = 1_000_000; s.dread = 1_000_000; const deepen = UPGRADES.find(u => u.id === 'deepen_ritual')!; let lastCost = 0; for (let i = 0; i < 8; i++) { const cost = deepen.cost(s).andakt!; expect(cost).toBeGreaterThan(lastCost); lastCost = cost; s = buyUpgrade(s, 'deepen_ritual'); } }); }); describe('inflytande (influence) accumulation', () => { it('scales faster than linearly with troende (a network effect)', () => { function inflytandeAfter(troende: number, ticks: number): number { let s = createState(); s.troende = troende; s.andaktRate = 0; // isolate influence growth for (let i = 0; i < ticks; i++) s = tick(s); return s.inflytande; } const at2 = inflytandeAfter(2, 300); const at4 = inflytandeAfter(4, 300); const at8 = inflytandeAfter(8, 300); // Doubling troende should more than double influence gained (superlinear). expect(at4).toBeGreaterThan(at2 * 2); expect(at8).toBeGreaterThan(at4 * 2); }); }); describe('exert_influence', () => { it('restores hemlighet at the cost of inflytande, and refuses purchase when inflytande is insufficient', () => { let s = createState(); s.troende = 2; s.hemlighet = 50; s.inflytande = 0; const blocked = buyUpgrade(s, 'exert_influence'); expect(blocked.inflytande).toBe(0); expect(blocked.hemlighet).toBe(50); s.inflytande = 100; const after = buyUpgrade(s, 'exert_influence'); expect(after.hemlighet).toBeGreaterThan(50); expect(after.inflytande).toBeLessThan(100); }); }); describe('buyUpgrade immutability', () => { it('does not mutate the input state', () => { const s = createState(); s.andakt = 1000; s.troende = 2; const frozenTroende = s.troende; const frozenAndakt = s.andakt; buyUpgrade(s, 'recruit_neighbor'); expect(s.troende).toBe(frozenTroende); expect(s.andakt).toBe(frozenAndakt); }); });