/** * 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 => ({ 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 = {}, 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 42 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. for (const seed of [1, 7, 42]) { 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(42); const me = state.tally.byPlayer[0]!; assert.ok(me.revenueGained > 0, 'seed 42 earned nothing — the gained half is not being counted'); assert.ok(me.revenueLost > 0, 'seed 42 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'); }); });