Files
diariet/src/engine.test.ts
Fredrik Johansson 5e5ed2ec7e
All checks were successful
Docker / build-and-push (push) Successful in 49s
Add real replay variety: 15 new reports, random per-day roster
Same fix as Retur (which this engine is descended from): every
playthrough showed the same fixed 25 reports in the same order, with
zero randomization anywhere. Added 15 new reports (3 per day,
d26-d40) citing more of Djupet's event pool (the bridge's impossible
vibration frequency, the investigator in the grey suit whose shadow
didn't follow, a mirror showing an older version of the room),
bringing each day's pool to 8, and changed the engine to draw
PER_DAY_COUNT (5) at random per game instead of a fixed set.

Same shape as Retur's fix: buildRoster() shuffles and slices each
day's pool, GameState carries a `roster` generated once at
createState() and persisted, reportsForDay/currentReport read through
it. Save key bumped to v2 for the same reason — no backfill for old
saves, just start fresh.

Tests rewritten the same way: buildRoster() property tests plus
engine tests that either check general correctness or construct an
explicit roster when a specific report's behavior needs locking in
(d04's escalation-even-when-correct case).

Verified: 12/12 vitest, tsc clean, real browser test confirming 4
distinct first-report sources across 5 fresh games.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 21:12:32 +02:00

126 lines
5.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { buildRoster, createState, currentReport, reportsForDay, nextShift, routeReport, startGame, PER_DAY_COUNT, LAST_DAY } from './engine.ts';
import { REPORTS } from './data/content.ts';
// A fixed sequence, not a real PRNG — deterministic and enough entropy to
// exercise the shuffle without pulling in a dependency for tests only.
function fakeRng(seed: number): () => number {
let s = seed;
return () => {
s = (s * 9301 + 49297) % 233280;
return s / 233280;
};
}
function stateWithRoster(roster: string[]) {
return { ...startGame(createState()), roster };
}
describe('buildRoster', () => {
it('draws exactly PER_DAY_COUNT reports per day from that day\'s pool', () => {
const roster = buildRoster(fakeRng(1));
expect(roster).toHaveLength(PER_DAY_COUNT * LAST_DAY);
for (let day = 1; day <= LAST_DAY; day++) {
const ids = roster.slice((day - 1) * PER_DAY_COUNT, day * PER_DAY_COUNT);
expect(ids).toHaveLength(PER_DAY_COUNT);
expect(new Set(ids).size).toBe(PER_DAY_COUNT); // no duplicates within a day
for (const id of ids) {
const report = REPORTS.find((r) => r.id === id);
expect(report, `roster id ${id} has no matching report`).toBeTruthy();
expect(report!.day).toBe(day);
}
}
});
it('each day\'s pool has more reports than get drawn, so variety is possible', () => {
for (let day = 1; day <= LAST_DAY; day++) {
const poolSize = REPORTS.filter((r) => r.day === day).length;
expect(poolSize).toBeGreaterThan(PER_DAY_COUNT);
}
});
it('different seeds produce different rosters', () => {
const a = buildRoster(fakeRng(1));
const b = buildRoster(fakeRng(99));
expect(a).not.toEqual(b);
});
});
describe('routing engine', () => {
it('starts on a real report from shift one', () => {
const state = startGame(createState());
const report = currentReport(state);
expect(state.phase).toBe('playing');
expect(report).toBeTruthy();
expect(report!.day).toBe(1);
});
it('advances and records a correct route', () => {
const before = startGame(createState());
const report = currentReport(before)!;
const state = routeReport(before, report.correctRoute);
expect(state.reportIndex).toBe(1);
expect(state.decisions[0].correct).toBe(true);
expect(state.trust).toBe(3);
});
it('penalizes an incorrect route', () => {
const before = startGame(createState());
const report = currentReport(before)!;
const wrongRoute = (['avfarda', 'arkivera', 'utred', 'eskalera'] as const).find((r) => r !== report.correctRoute)!;
const state = routeReport(before, wrongRoute);
expect(state.decisions[0].correct).toBe(false);
expect(state.trust).toBeLessThan(3);
expect(state.insight).toBeGreaterThan(0);
});
it('escalating costs insight even when it is the correct route (d04, hand-picked into the roster)', () => {
const roster = ['d01', 'd02', 'd03', 'd04', 'd05', ...buildRoster(fakeRng(2)).slice(PER_DAY_COUNT)];
let state = stateWithRoster(roster);
for (const route of ['avfarda', 'arkivera', 'utred'] as const) state = routeReport(state, route);
expect(currentReport(state)?.id).toBe('d04');
state = routeReport(state, 'eskalera');
expect(state.lastDecision?.correct).toBe(true);
expect(state.lastDecision?.trustCost).toBe(0);
expect(state.lastDecision?.insightCost).toBe(1);
});
it('escalating a wrong report stacks the escalation cost on top of the wrong-route cost', () => {
const roster = ['d01', 'd02', 'd03', 'd04', 'd05', ...buildRoster(fakeRng(3)).slice(PER_DAY_COUNT)];
let state = stateWithRoster(roster);
// d01's correct route is avfarda; eskalera is wrong here.
state = routeReport(state, 'eskalera');
expect(state.lastDecision?.correct).toBe(false);
expect(state.lastDecision?.insightCost).toBe(2); // 1 for wrong + 1 for escalating
});
it('ends a shift and restores one trust point for the next', () => {
let state = startGame(createState());
const routes = reportsForDay(state, 1).map((report) => report.correctRoute);
for (const route of routes) state = routeReport(state, route);
expect(state.phase).toBe('shift-end');
state = nextShift(state);
expect(state.day).toBe(2);
expect(state.trust).toBe(3);
});
it('has PER_DAY_COUNT reports available in every shift', () => {
const state = startGame(createState());
for (let day = 1; day <= LAST_DAY; day += 1) expect(reportsForDay(state, day)).toHaveLength(PER_DAY_COUNT);
});
it('every report has its own correct-route consequence', () => {
for (const report of REPORTS) {
expect(report.correctConsequence, `${report.id} is missing a correctConsequence`).toBeTruthy();
}
});
it('loses on insight reaching the cap even with trust intact', () => {
let state = startGame(createState());
// Escalate every report regardless of correctness to ramp insight fast.
for (let i = 0; i < 10 && state.phase === 'playing'; i++) state = routeReport(state, 'eskalera');
expect(state.phase).toBe('lost');
expect(state.insight).toBeGreaterThanOrEqual(5);
});
});