Add Inflytande resource, expand events, and balance the endgame
All checks were successful
Docker / build-and-push (push) Successful in 42s
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:
199
src/engine.test.ts
Normal file
199
src/engine.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user