Add real replay variety: 15 new reports, random per-day roster
All checks were successful
Docker / build-and-push (push) Successful in 49s
All checks were successful
Docker / build-and-push (push) Successful in 49s
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>
This commit is contained in:
@@ -1,31 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createState, currentReport, reportsForDay, nextShift, routeReport, startGame } from './engine.ts';
|
||||
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 the first report of shift one', () => {
|
||||
it('starts on a real report from shift one', () => {
|
||||
const state = startGame(createState());
|
||||
const report = currentReport(state);
|
||||
expect(state.phase).toBe('playing');
|
||||
expect(currentReport(state)?.id).toBe('d01');
|
||||
expect(report).toBeTruthy();
|
||||
expect(report!.day).toBe(1);
|
||||
});
|
||||
|
||||
it('advances and records a correct route', () => {
|
||||
const state = routeReport(startGame(createState()), 'avfarda');
|
||||
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 state = routeReport(startGame(createState()), 'arkivera');
|
||||
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).toBe(2);
|
||||
expect(state.insight).toBe(1);
|
||||
expect(state.trust).toBeLessThan(3);
|
||||
expect(state.insight).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('escalating costs insight even when it is the correct route', () => {
|
||||
// d04 (Turning Torso rotating) is correctly routed eskalera.
|
||||
let state = startGame(createState());
|
||||
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');
|
||||
@@ -35,7 +86,8 @@ describe('routing engine', () => {
|
||||
});
|
||||
|
||||
it('escalating a wrong report stacks the escalation cost on top of the wrong-route cost', () => {
|
||||
let state = startGame(createState());
|
||||
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);
|
||||
@@ -44,17 +96,17 @@ describe('routing engine', () => {
|
||||
|
||||
it('ends a shift and restores one trust point for the next', () => {
|
||||
let state = startGame(createState());
|
||||
const routes = reportsForDay(1).map((report) => report.correctRoute);
|
||||
state = routeReport(state, 'arkivera'); // deliberately wrong on d01
|
||||
for (const route of routes.slice(1)) state = routeReport(state, route);
|
||||
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 five authored reports in every shift', () => {
|
||||
for (let day = 1; day <= 5; day += 1) expect(reportsForDay(day)).toHaveLength(5);
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user