Expand game engine, replay, tests, and documentation

This commit is contained in:
Jesse
2026-07-31 08:10:54 -04:00
parent e7bc07df85
commit 401b879140
30 changed files with 2253 additions and 552 deletions
+67 -13
View File
@@ -11,6 +11,7 @@ import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
import { simulate } from '../src/sim/harness.ts';
import { record } from '../src/sim/replay.ts';
import { anomalies, strategyBuckets } from '../src/sim/stats.ts';
const config: GameConfig = {
@@ -152,16 +153,22 @@ describe('the revenue chain works end to end (regression)', () => {
it('instantiates a real Facility when a freight card is placed', () => {
// REGRESSION. Placed facility cards were built with `facility: null` — inert, unworkable.
const s = createGame({ id: 'g', seed: 11, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
//
// Checked across several seeds: the design's deck holds only 9 industries in 115 cards, so a
// single game may legitimately never draw one. That dilution is itself worth knowing — under
// the old 52-card placeholder it was 10 in 52.
let placed = 0;
for (const card of s.officeAreas.get(0)!.grid.values()) {
if (card.geometry.kind !== 'facility') continue;
placed++;
assert.ok(card.facility, 'a placed facility card has no Facility record');
assert.ok(card.facility.laborers > 0, 'facility has no Laborers');
for (const seed of [11, 23, 47, 91, 137, 211]) {
const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
for (const card of s.officeAreas.get(0)!.grid.values()) {
if (card.geometry.kind !== 'facility') continue;
placed++;
assert.ok(card.facility, 'a placed facility card has no Facility record');
assert.ok(card.facility.laborers > 0, 'facility has no Laborers');
}
}
assert.ok(placed > 0, 'no facility was placed at all');
assert.ok(placed > 0, 'no facility was placed across six campaign games');
});
it('earns freight revenue, not just passenger revenue', () => {
@@ -180,16 +187,17 @@ describe('the revenue chain works end to end (regression)', () => {
assert.ok(freight > 0, 'no freight load completed across 40 games');
});
it('grows the Office Area in both directions (Gap 11)', () => {
// With orientation fixed, grids only ever grew sideways and downward.
it('grows the Office Area downward from the Running Track (Q7)', () => {
// The card art puts the through-track at the top edge, so Secondary Track hangs beneath it.
// Nothing should ever be built above row 0.
const rows = new Set<number>();
for (const seed of [3, 9, 27, 81]) {
const s = createGame({ id: 'g', seed, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] });
playGame(s, developerBot, pump);
for (const key of s.officeAreas.get(0)!.grid.keys()) rows.add(Number(key.split(',')[0]));
}
assert.ok([...rows].some((r) => r > 0), 'nothing was ever built north of the Running Track');
assert.ok([...rows].some((r) => r < 0), 'nothing was ever built south of the Running Track');
assert.ok([...rows].some((r) => r < 0), 'nothing was ever built below the Running Track');
assert.ok(![...rows].some((r) => r > 0), 'something was built ABOVE the Running Track');
});
});
@@ -233,3 +241,49 @@ describe('end-of-game statistics', () => {
assert.equal(bucketed, scored.length, 'a scoring game fell outside every bucket');
});
});
describe('switching accomplishes something (regression)', () => {
it('does not shuttle the crew back and forth to no purpose', () => {
// REGRESSION. The bot chose to SWITCH whenever a train stood at the Office, whether or not
// there was anything to switch — then, having nothing useful to do, took the first legal move
// repeatedly and burned all six Moves oscillating between two cells. A passenger train needs
// no switching at all (§9.2 works coaches straight off the A/D track), so an entire Local
// Operations action was wasted.
const rec = record(1234, 'standard');
let worstRun = 0;
let run = 0;
let lastFrom: string | null = null;
for (const f of rec.frames) {
const move = f.lines.find((l) => /^CREW moved/.test(l.text));
const didWork = f.lines.some((l) => /Dropped|Coupled/.test(l.text));
if (!move || didWork) {
run = 0;
lastFrom = null;
continue;
}
const m = /\((-?\d+),(-?\d+)\) → \((-?\d+),(-?\d+)\)/.exec(move.text);
if (!m) continue;
const from = `${m[1]},${m[2]}`;
const to = `${m[3]},${m[4]}`;
// An oscillation is a move that lands exactly where the previous move started.
run = lastFrom === to ? run + 1 : 0;
worstRun = Math.max(worstRun, run);
lastFrom = from;
}
assert.ok(worstRun < 3, `crew oscillated ${worstRun + 1} times without doing any work`);
});
it('does not spend its whole Local Operations budget on motion', () => {
// The absolute ratio is no longer a fair test: with only 9 industries in 115 cards there is
// often nothing to switch, so `work` is legitimately near zero. What still must hold is that
// the crew is not burning all six Moves every Stage — the shuttling bug.
const report = simulate({
games: 40, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
});
const moves = report.perGame.reduce((n, g) => n + g.actions.moves, 0) / report.perGame.length;
assert.ok(moves < 60, `${moves.toFixed(0)} Moves per game suggests aimless shuttling`);
});
});