Files
djupet/src/engine.test.ts
Fredrik Johansson bde809e862
All checks were successful
Docker / build-and-push (push) Successful in 43s
Extend the endgame, add more content, make kaos events recur
Balance: an optimal-play simulation showed the game finishing in
under 3 minutes, mostly because torso_beacon + grand_ritual_prep
alone pushed ritualRate to 2.5 — enough to clear the last 50 ritual
points in 20 seconds. Softened those boosts (0.5→0.15, 2→0.35),
raised deepen_ritual's cost curve so it's a real endgame sink instead
of a one-off top-up, and added two new late-game upgrades (sunken
choir, nine filled chairs) between grand_ritual_prep and the win.
Re-simulated: a greedy optimal-play bot now takes ~5 minutes instead
of ~3 and surfaces ~40 distinct events instead of ~30 out of a much
larger pool — real (non-bot) play should run considerably longer.

Content: added 8 new high-dread late-game standard events (gated up
to minDread 400, since nothing previously escalated past 75) and 7
new kaos events, plus one more choice event.

Kaos events no longer get marked as permanently used — they're
chaos-relief flavor, not narrative progression, and a ~24-entry pool
(now 30) was depleting for good over a long game. Standard/chain
events remain single-use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 19:29:04 +02:00

204 lines
6.5 KiB
TypeScript

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 = 0.5; // baseline after torso_beacon (0.15) + grand_ritual_prep (0.35)
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);
});
});