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
219 lines
11 KiB
TypeScript
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');
|
|
});
|
|
});
|