Add Inflytande resource, expand events, and balance the endgame
All checks were successful
Docker / build-and-push (push) Successful in 42s

Adds an influence currency that scales with follower count and a new
"exert influence" action to trade it for secrecy, uncaps recruiting
with scaling cost, adds a late-game Andakt sink, and makes hemlighet
drain scale with dread/ritual progress so the endgame carries real
risk. Includes a Vitest suite that simulates play to check balance,
which caught the influence-accrual rate being too slow to be useful
early game.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-08 17:28:32 +02:00
parent fd457ff312
commit 0efbe9d55b
8 changed files with 875 additions and 30 deletions

199
src/engine.test.ts Normal file
View File

@@ -0,0 +1,199 @@
import { describe, it, expect, afterEach } from 'vitest';
import { createState, tick, buyUpgrade, type State } from './engine.ts';
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);
});
});