/** * The event tally — Gitea#16's statistics, folded from the event stream into `GameState.tally`. * * WHERE IT IS HOOKED, and why it is not in `reduce`. `apply.ts`'s `reduce` sees only the events an * INTENT produced; `advance.ts` mutates state directly and pushes its events without reducing them * at all — and `advance` is where `trainCompleted`, `trainsDestroyed` and `trainStoodStill` come * from, which are exactly the numbers this issue asks for. So the fold is hooked at the two places * every event in the game passes through exactly once on its way to a caller: * * - `applyIntent` (`apply.ts`), beside its `reduce` loop; * - `advance` (`advance.ts`), which now wraps the phase driver and folds what it returns. * * Exactly once matters in both directions: an event folded twice inflates a count, and an event * folded nowhere is a statistic that silently reads zero. `test/tally.test.ts` pins both by playing * real games and checking the tally against an independent count over the same event array. * * NOTHING HERE IS A RULE. The tally is read by the results screen and by the badge work Gitea#16 * leaves to a second pass; no engine decision consults it. That is what makes adding a counter * always safe. * * WHAT IS NOT COUNTED PER PLAYER, and why. `carsCoupled` and `carsDropped` carry a `trayId` and no * `player` — switching is done BY a crew, and the event says which crew rather than which person. * Rather than guess an owner from whose turn it happened to be, those two are table totals only. * The events that do name a player (`loadCompleted`, `passengersBoarded`, `cardPlayed`, * `revenueChanged`, `trainsDestroyed`) are the ones `byPlayer` reports. */ import type { GameEvent } from './events.ts'; import type { GameState } from './state.ts'; /** * Fold one event into `s.tally`. * * The switch is deliberately not exhaustive — most of the 49 event types say nothing a player would * want counted, and listing them all to `break` would bury the ones that do. A `default` that does * nothing is the honest shape. */ export function tallyEvent(s: GameState, e: GameEvent): void { const t = s.tally; const mine = 'player' in e && typeof e.player === 'number' ? t.byPlayer[e.player] : undefined; switch (e.type) { /** * §X18 — the Circus train set up and was paid for the Stage it spent standing. * * NOT a "longest stand" streak, which is what Gitea#16 wants and what its comment assumed this * event was. It fires once in a game per such train: only trains whose profile sets * `stopEarnsPoint` emit it at all, and `advance.ts` claims it once with `stopPointClaimed`. So * there is nothing to count a run of, and the honest thing to report is the event itself. */ case 'trainStoodStill': t.circusStops.push({ trainNumber: e.trainNumber, where: e.where }); break; /** * DID THIS TRAIN DO ANY SWITCHING — Gitea#16's "switching master" join, kept as it happens * rather than reconstructed afterwards. * * The two halves of the join are in different currencies: switching events name a `trayId` and * completion names a `trainNumber`, and no event carries both. The tray is looked up in LIVE * state, which is sound precisely here — a crew that has just coupled or dropped is still on the * board — where re-deriving it at completion time would not be, the tray having been released by * then. A lookup that misses costs one train its mark on a statistic; it cannot affect a rule. */ case 'carsCoupled': t.carsCoupled += e.stock.length; markSwitched(s, e.trayId); break; case 'carsDropped': t.carsDropped += e.stock.length; markSwitched(s, e.trayId); break; case 'flyingSwitch': t.flyingSwitches += 1; markSwitched(s, e.trayId); break; // A tray is reused run after run, so a fresh train starts with a clean sheet. case 'trainMadeUp': t.switchedSince = t.switchedSince.filter((n) => n !== e.trainNumber); break; case 'trainCompleted': t.trainsCompleted += 1; if (t.switchedSince.includes(e.trainNumber)) t.trainsCompletedWithWork += 1; t.switchedSince = t.switchedSince.filter((n) => n !== e.trainNumber); break; case 'trainsDestroyed': t.trainsDestroyed += e.trains.length; for (const train of e.trains) t.carsDestroyed += train.consist.length; if (mine) mine.collisions += 1; break; case 'officeUpgraded': t.officeUpgrades += 1; break; case 'dispatchBonusUsed': t.dispatchBonusesUsed += 1; break; case 'facilityUnjammed': t.facilitiesUnjammed += 1; break; case 'expediteFault': t.expediteFaults += 1; break; case 'trainHeld': t.trainsHeld += 1; break; case 'trainDiverted': t.trainsDiverted += 1; break; case 'secondSectionOrdered': t.secondSections += 1; break; case 'extraStarted': t.extrasStarted += 1; break; case 'cardDrawn': t.cardsDrawn += 1; break; case 'cardPlayed': t.cardsPlayed += 1; if (mine) mine.cardsPlayed += 1; break; case 'cardDiscarded': t.cardsDiscarded += 1; break; case 'clearanceRequested': t.clearancesRequested += 1; break; // §8.1 — a ruling is given either way; only a YES let the other train through. case 'clearanceGiven': if (e.allow) t.clearancesAllowed += 1; break; case 'loadStarted': t.loadsStarted += 1; break; case 'loadCompleted': t.loadsCompleted += 1; if (mine) mine.loads += 1; break; case 'unloadBegan': t.unloadsBegun += 1; break; case 'unloadCompleted': t.unloadsCompleted += 1; if (mine) mine.unloads += 1; break; case 'passengersBoarded': t.passengersBoarded += 1; if (mine) mine.passengersBoarded += 1; break; case 'passengersDetrained': t.passengersDetrained += 1; if (mine) mine.passengersDetrained += 1; break; /** * Gained and lost are kept APART because the net is already on `players[i].revenue`. What the * results screen cannot otherwise say is how much of a modest final score was earned and then * handed back at a grade crossing — which is the whole difference between a quiet game and an * eventful one. */ case 'revenueChanged': if (mine) { if (e.delta >= 0) mine.revenueGained += e.delta; else mine.revenueLost += -e.delta; } break; default: break; } } function markSwitched(s: GameState, trayId: string): void { const n = s.trays.get(trayId)?.trainNumber; if (n === undefined || n === null) return; if (!s.tally.switchedSince.includes(n)) s.tally.switchedSince.push(n); }