Files
station-master/test/tally.test.ts
T
Jesse.MarkowitzandClaude Opus 5 4adf149ba5 v0.8.0.10 — playtest fixes: clearance rulings, the log, the map, and a save file
From the first two multiplayer playtests of v0.8.0.9, each traced before fixing.

The engine:

- A train on a card BEHIND the one departing no longer triggers a clearance
  ruling or an opposite-direction bar (#26). Reproduced from the exported
  save: X15 was held over X18 behind it, and X18 then collided into the full
  Whistle Post. Games in progress holding a ruling the engine no longer asks
  for will not resume (28 of 40 recorded four-seat games); shipped as is at
  Jesse's call.
- `mainlineModified` carries the card's previous kind, so the log can say
  what a Realignment converted (#27).

The screen:

- The turn chart and the Division map name the player whose move is on
  screen while bot turns replay, not the live actor (#25).
- The owning player's name is no longer outlined by the turn arrow's stroke,
  which made it unreadable (#24).
- A Mainline card flashes on the map when a Realignment changes it (#28).
- The history is held back with the board and revealed step by step, instead
  of arriving whole while the board is still catching up (#29).
- A ruling made by holding the office reads "Superintendent Player X" (#30),
  and no line names a player twice (#31).
- A seated player can download their own game as a save file: the play
  page's Save replay button, fed by GET /api/save?token=… (#32). The StartOS
  action cannot do this — an action result is text only.

Closes #24
Closes #25
Closes #26
Closes #27
Closes #28
Closes #29
Closes #30
Closes #31
Closes #32

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-15 22:44:20 -04:00

219 lines
11 KiB
TypeScript

/**
* THE EVENT TALLY — Gitea#16's statistics, and the one property they depend on.
*
* "I don't know if we keep statistics on…" is the question the issue opens with. Nothing was being
* kept; `GameState.tally` now is, folded from the event stream at the two places every event passes
* through (`engine/tally.ts` explains which and why).
*
* The property that matters is EXACTLY ONCE. A statistic folded twice reads high and a statistic
* folded nowhere reads zero, and both are indistinguishable from a quiet game when you are looking
* at a results screen. So the central test here does not assert particular numbers: it plays real
* games, collects every event the engine emitted along the way, counts them independently, and
* checks the tally against that count. A fold hooked in the wrong place fails it whatever the seed.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { advance } from '../src/engine/advance.ts';
import { tallyEvent } from '../src/engine/tally.ts';
import { applyIntent } from '../src/engine/apply.ts';
import { STAGES_PER_DAY } from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import { developerBot } from '../src/sim/bot.ts';
import type { GameEvent } from '../src/engine/events.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
mode: 'solitaire',
days: 3,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
...over,
});
/** Plays a whole game with the developer bot, keeping every event the engine produced. */
function playKeepingEvents(
seed: number,
over: Partial<GameConfig> = {},
names = ['Jesse'],
): { state: GameState; events: GameEvent[] } {
const state = createGame({ id: 'g', seed, config: baseConfig(over), playerNames: names });
const events: GameEvent[] = [];
for (let i = 0; i < 20_000; i++) {
const r = advance(state);
events.push(...r.events);
if (state.status === 'finished') break;
if (!r.needsInput) continue;
// The bot declines an extension, so this terminates on the timetable it was dealt (Gitea#11).
const actor =
state.status === 'awaitingExtension'
? state.extensionVotes.findIndex((v) => v === null)
: state.clock.pendingDecision !== null
? state.clock.superintendent
: state.clock.currentActor;
if (actor === null || actor < 0) break;
const options = legalActions(state, actor);
if (options.length === 0) break;
const applied = applyIntent(state, actor, developerBot.choose(state, actor, options));
if (!applied.ok) break;
events.push(...applied.events);
}
return { state, events };
}
/** Counts events the way `tally.ts` should have, without sharing any of its code. */
function countIndependently(events: GameEvent[]) {
let sum = 0;
const n = (type: GameEvent['type']): number => events.filter((e) => e.type === type).length;
for (const e of events) {
if (e.type === 'carsCoupled' || e.type === 'carsDropped') sum += e.stock.length;
}
return {
trainsCompleted: n('trainCompleted'),
loadsCompleted: n('loadCompleted'),
unloadsCompleted: n('unloadCompleted'),
loadsStarted: n('loadStarted'),
unloadsBegun: n('unloadBegan'),
passengersBoarded: n('passengersBoarded'),
passengersDetrained: n('passengersDetrained'),
cardsDrawn: n('cardDrawn'),
cardsPlayed: n('cardPlayed'),
cardsDiscarded: n('cardDiscarded'),
officeUpgrades: n('officeUpgraded'),
flyingSwitches: n('flyingSwitch'),
extrasStarted: n('extraStarted'),
secondSections: n('secondSectionOrdered'),
trainsHeld: n('trainHeld'),
trainsDiverted: n('trainDiverted'),
expediteFaults: n('expediteFault'),
facilitiesUnjammed: n('facilityUnjammed'),
dispatchBonusesUsed: n('dispatchBonusUsed'),
clearancesRequested: n('clearanceRequested'),
switchedCars: sum,
};
}
describe('the tally counts every event exactly once (Gitea#16)', () => {
for (const seed of [1, 7, 42, 116956197]) {
it(`agrees with an independent count of the event stream — seed ${seed}`, () => {
const { state, events } = playKeepingEvents(seed);
const want = countIndependently(events);
const t = state.tally;
assert.equal(t.trainsCompleted, want.trainsCompleted, 'trains through the Division');
assert.equal(t.loadsCompleted, want.loadsCompleted, 'loads made up');
assert.equal(t.unloadsCompleted, want.unloadsCompleted, 'loads broken');
assert.equal(t.loadsStarted, want.loadsStarted, 'loads started');
assert.equal(t.unloadsBegun, want.unloadsBegun, 'unloads begun');
assert.equal(t.passengersBoarded, want.passengersBoarded, 'passengers boarded');
assert.equal(t.passengersDetrained, want.passengersDetrained, 'passengers detrained');
assert.equal(t.cardsDrawn, want.cardsDrawn, 'cards drawn');
assert.equal(t.cardsPlayed, want.cardsPlayed, 'cards played');
assert.equal(t.cardsDiscarded, want.cardsDiscarded, 'cards discarded');
assert.equal(t.officeUpgrades, want.officeUpgrades, 'offices upgraded');
assert.equal(t.flyingSwitches, want.flyingSwitches, 'flying switches');
assert.equal(t.extrasStarted, want.extrasStarted, 'extras started');
assert.equal(t.secondSections, want.secondSections, 'second sections');
assert.equal(t.trainsHeld, want.trainsHeld, 'trains held');
assert.equal(t.trainsDiverted, want.trainsDiverted, 'trains diverted');
assert.equal(t.expediteFaults, want.expediteFaults, 'expedite faults');
assert.equal(t.facilitiesUnjammed, want.facilitiesUnjammed, 'facilities unjammed');
assert.equal(t.dispatchBonusesUsed, want.dispatchBonusesUsed, 'dispatch bonuses');
assert.equal(t.clearancesRequested, want.clearancesRequested, 'clearances requested');
assert.equal(t.carsCoupled + t.carsDropped, want.switchedCars, 'cars switched');
});
}
it('actually counted something — a tally of zeroes would pass the check above vacuously', () => {
// The trap `stats.ts` warns about in its own doc comment: "this never happened" is a finding,
// not something to scroll past. A fold hooked nowhere at all agrees perfectly with an
// independent count of an event stream nobody looked at, so the exactly-once tests above cannot
// catch it on their own.
//
// ASSERTED ON WHAT THE DEVELOPER BOT ACTUALLY DOES, which is not much: `node src/sim/harness.ts
// 12 standard` means 1.1 Revenue per game at an 8% freight share and loses every game on the
// revenue floor, and it goes whole games without coupling a single car. That is a known
// property of the bot (TODO.md, Bot Performance) and not this fold's business — so this test
// asserts on traffic and cards, which happen in every game, rather than on switching, which
// would make it a bot-strength test wearing a statistics test's clothes.
const { state, events } = playKeepingEvents(1);
assert.ok(events.length > 500, `only ${events.length} events — the game barely ran`);
assert.ok(state.tally.cardsDrawn > 0, 'no cards were drawn all game');
assert.ok(state.tally.trainsCompleted > 0, 'no train ever left the Division');
assert.ok(state.tally.cardsPlayed > 0, 'no card was ever played');
});
it('splits Revenue into what was earned and what was given back', () => {
// Reconciliation is the real assertion and it holds for any game, earned or not: gained minus
// lost IS the score the engine kept. Seed 44 is named because it is one where Revenue actually
// moves in both directions — it earns 1 and gives back 5 to a collision — so the two halves are
// being told apart rather than both sitting at zero.
//
// It was seed 42 until v0.8.0.10. That game's collision was the Superintendent holding a train over
// one BEHIND it (Gitea#26); with the ruling gone the collision is too, and seed 42 now earns 5 and
// loses nothing — a better game and a vacuous test. The seed moved, not the assertion.
for (const seed of [1, 7, 44]) {
const { state } = playKeepingEvents(seed);
const me = state.tally.byPlayer[0]!;
assert.equal(
me.revenueGained - me.revenueLost,
state.players[0]!.revenue,
`seed ${seed}: gained minus lost does not reconcile with the score the engine kept`,
);
}
const { state } = playKeepingEvents(44);
const me = state.tally.byPlayer[0]!;
assert.ok(me.revenueGained > 0, 'seed 44 earned nothing — the gained half is not being counted');
assert.ok(me.revenueLost > 0, 'seed 44 lost nothing — the lost half is not being counted');
});
it('records a Circus set-up as the one-off it is, not as a streak', () => {
/**
* `trainStoodStill` is NOT "this train did not move this Stage". It fires only for a train whose
* profile sets `stopEarnsPoint` — the X18 Circus — and `advance.ts` claims it once per train
* with `stopPointClaimed`, so it can never fire twice for the same one.
*
* Gitea#16 asks for "longest engine sat on a siding" and its comment assumed this event would
* answer it. It cannot, and a streak folded from it would have read "1 Stage" for ever. Pinned
* here so that the day a real per-Stage signal is added, whoever adds it finds this test rather
* than the old wrong assumption.
*/
const s = createGame({ id: 'g', seed: 1, config: baseConfig(), playerNames: ['Jesse'] });
const feed = (e: GameEvent): void => tallyEvent(s, e);
feed({ type: 'trainStoodStill', trainNumber: 18, where: '(0,0)' });
assert.deepEqual(s.tally.circusStops, [{ trainNumber: 18, where: '(0,0)' }]);
assert.ok(!('longestStand' in s.tally), 'a streak that cannot be computed is being reported');
});
});
describe('the official result freezes the tally with it (Gitea#11 + #16)', () => {
it('records the statistics as they stood when the timetable ran out', () => {
const { state } = playKeepingEvents(1);
assert.ok(state.official, 'no official result was recorded');
// Nothing was played after the ending in this game, so the two agree — which is the check that
// the freeze happens AFTER the last batch of events is folded rather than before it.
assert.equal(state.official!.tally.trainsCompleted, state.tally.trainsCompleted);
assert.ok(state.official!.tally.cardsDrawn > 0, 'the frozen tally is empty');
});
it('keeps the frozen copy still while the live tally moves on', () => {
const s = createGame({ id: 'g', seed: 1, config: baseConfig(), playerNames: ['Jesse'] });
s.clock.day = s.config.days + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
advance(s);
const frozen = s.official!.tally.cardsDrawn;
applyIntent(s, 0, { type: 'game.extend', player: 0, agree: true });
tallyEvent(s, { type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'x' });
assert.equal(s.tally.cardsDrawn, frozen + 1, 'the live tally did not move');
assert.equal(s.official!.tally.cardsDrawn, frozen, 'the frozen tally moved with it');
});
});