2026-07-08 17:28:32 +02:00
|
|
|
import { describe, it, expect, afterEach } from 'vitest';
|
2026-07-13 09:54:46 +02:00
|
|
|
import { createState as createIntroState, startGame, tick, buyUpgrade, type State } from './engine.ts';
|
|
|
|
|
|
|
|
|
|
function createState(): State {
|
|
|
|
|
return startGame(createIntroState());
|
|
|
|
|
}
|
2026-07-08 17:28:32 +02:00
|
|
|
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');
|
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
|
|
|
s.ritualRate = 0.5; // baseline after torso_beacon (0.15) + grand_ritual_prep (0.35)
|
2026-07-08 17:28:32 +02:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|