Four things the game counted and never said, and two it said wrong

Stays in the unshipped v0.7.9. Prompted by Jesse asking the general
question after two v0.7.9 fixes turned out to be the same shape:
actingPlayer existed and the Frame threw it away, and collisionsToday /
collisionsTotal rode the Frame for three releases with nothing drawing
them. So what else is computed, serialised and sent to nobody?

THE AUDIT, done rather than guessed. Every one of Frame's 59 top-level
fields grepped for a read across the seven renderers, then the same for
Tally's 26 members. 55 of 59 are read. Four are not.

tally.unloadsBegun was visible rather than merely unused. §9.1 makes
loading and unloading the same shape — begun, then carried through —
and the results screen printed "Loads still in the pipeline" for one
side and nothing for the other, reporting half of a symmetric
mechanism. tally.cardsDiscarded was counted by the engine and listed
beside "Cards drawn" and "Cards played" without it, though Gitea#9 made
throwing a Timetabled train away a deliberate move — a player CHOICE
the game counted and never reported. Both are reported now.

viewerSeat and overHandLimit are deferred by Jesse. The second is the
fullest version of the shape: engine computes it, view.ts puts it on
the Frame, session.ts declares it on the Session interface AND
implements it twice, and the only caller in the repo is its own test.
Four layers of plumbing, no consumer. The decision when it comes is
delete-or-document, not a patch.

Fixing the two turned up a third thing: resultsHtml draws
tallyHtml(report?.tally ?? f.tally), and report is f.official, so a
finished game reports the tally frozen at the official ending rather
than the live one. The first attempt at a test overrode f.tally alone,
changed nothing on screen, and failed for a reason unrelated to the
fix.

A SHOUTED KEYWORD IS NOT A SENTENCE. `EXTRA X18 started…` attributed to
a player rendered as `Player Solitaire eXTRA X18 started…`, and the
same happened to TRAIN 1 MADE UP and COLLISION. `record` folds a
narration's opening word into the middle of a sentence and did it with
a flat charAt(0).toLowerCase(). It now folds only a sentence-cased word
— ^[A-Z][a-z], a capital followed by a lower-case letter — which also
leaves X22 Pee-Dee alone, where a naive uppercase test gets it wrong
because '2'.toUpperCase() is '2'. It had been filed under Play Balance,
where it has no business being, which is how it survived a session that
had ruled balance work out of scope.

A REPLAYED SAVE NOW NARRATES WHAT THE LIVE GAME NARRATED. fromSave's
loop called record(game, result.events) with no actor, so every
restored save, every undo (which rebuilds through fromSave) and the
replay viewer stripped the "Player X" prefix off every attributed line.
submit attributes and fromMultiplayerSave attributes; this was the one
path of three that did not. One argument, with actor already computed
on the line above.

Why it survived: nothing ever compared a fromSave-built log against a
LIVE-played one. The single log-comparing test compares undo's rebuilt
log against another fromSave-built log — and undo itself rebuilds
through fromSave — so the gap cancelled out on both sides. The suite
was green with the bug in and green with it out. The new test plays a
game, saves it, restores it and asserts the two logs are identical:
the missing direction, not a new requirement.

All three fixes were confirmed to go RED with the fix reverted before
being called done.

884 tests pass, seventeen new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTaNBL1jVxNqgFdjHkHoo3
This commit is contained in:
Jesse.Markowitz
2026-08-30 19:44:25 -04:00
co-authored by Claude Opus 5
parent d267f89a82
commit e62ea54259
4 changed files with 212 additions and 10 deletions
+93
View File
@@ -1368,6 +1368,55 @@ describe('replays are saves', () => {
assert.deepEqual(back.log, fromSave(toSave(back)).log, 'the rebuilt log is not the replayed log');
});
it('replays a save into the same words the live game wrote', () => {
/**
* THE COMPARISON THAT WAS MISSING. `fromSave`'s loop called `record(game, result.events)` with
* no `actor`, so every restored save, every undo (which rebuilds through `fromSave`) and the
* replay viewer stripped the "Player X" prefix off every attributed line — describing the same
* moves in different words from the game that produced them.
*
* It went unnoticed because nothing compared a `fromSave`-built log against a LIVE-played one.
* The test above compares one rebuilt log against another rebuilt log, so the gap cancelled out
* on both sides and stayed green throughout. This plays a game, saves it, restores it, and
* asserts the two logs are identical — the direction that catches it.
*/
const live = newGame(202);
for (let i = 0; i < 30; i++) {
if (currentActor(live) === null) break;
const { options } = actionGroups(live);
if (options.length === 0 || !submit(live, options[0]!)) break;
}
const attributed = live.log.filter((l) => l.text.startsWith('Player '));
assert.ok(attributed.length > 0, 'the live game attributed nothing, so this proves nothing');
assert.deepEqual(
fromSave(toSave(live)).log,
live.log,
'a restored game does not narrate what the live game narrated',
);
});
it('does not mangle a narration that opens with a shouted keyword', () => {
/**
* `record` folds a narration's first word into the middle of a sentence — "Chose to draw" has to
* read "Player Bob chose to draw". It used to do that with a flat `charAt(0).toLowerCase()`, so
* every line opening with an all-caps keyword came out as `Player Solitaire eXTRA X18 started…`.
* The same happened to `TRAIN 1 MADE UP` and `COLLISION`, which are shouted on purpose.
*/
const game = newGame(202);
for (let i = 0; i < 120; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
const mangled = game.log.filter((l) => /^Player .* [a-z][A-Z]{2}/.test(l.text));
assert.deepEqual(mangled, [], 'a shouted keyword was lowercased into the middle of a word');
// And the ordinary case still folds, or the prefix would read "Player Bob Chose to draw".
const folded = game.log.filter((l) => /^Player \S+ [a-z]/.test(l.text));
assert.ok(folded.length > 0, 'nothing was folded at all — the prefix is no longer a sentence');
});
it('stays small enough to email', () => {
const game = newGame(202);
for (let i = 0; i < 400; i++) {
@@ -3624,6 +3673,50 @@ describe('the end-of-game results screen (Gitea#16)', () => {
assert.ok(html.includes('Trains through the Division'), 'the tally section is missing');
});
it('reports BOTH halves of the work pipeline, and cards discarded', () => {
/**
* FOUND 2026-08-30 auditing which `Frame` fields nothing reads. Two `Tally` members were
* counted by the engine and drawn by nothing:
*
* - `unloadsBegun`. §9.1 makes loading and unloading the same shape — begun, then carried
* through — and the screen printed "Loads still in the pipeline" for one side and nothing for
* the other, so it reported half of a symmetric mechanism.
* - `cardsDiscarded`. Gitea#9 made throwing a Timetabled train away a legal, deliberate move,
* so a discard is a CHOICE, listed beside draws and plays rather than left out of them.
*
* The tally is overridden rather than played into, because reaching a part-finished unload and
* a discard in the same seeded game is incidental to what is being checked here.
*/
const f = finished();
/**
* THE OFFICIAL REPORT'S TALLY IS THE ONE DRAWN, not the Frame's — `resultsHtml` renders
* `tallyHtml(report?.tally ?? f.tally)` and `report` is `f.official`, which the engine writes
* the moment any game ends. Overriding only `f.tally` here changed nothing at all and the test
* failed for a reason that had nothing to do with the fix, which is worth pinning in passing:
* a finished game reports the numbers frozen at the official ending, not the live ones.
*/
const withTally = (over: Partial<Frame['tally']>): Frame => ({
...f,
tally: { ...f.tally, ...over },
official: f.official ? { ...f.official, tally: { ...f.official.tally, ...over } } : f.official,
});
const html = resultsHtml(withTally({ unloadsBegun: 5, unloadsCompleted: 2, cardsDiscarded: 3 }));
assert.ok(html.includes('Unloads still in the pipeline'), 'the unloading pipeline is not reported');
assert.ok(html.includes('Cards discarded'), 'a deliberate discard is counted and never reported');
// The in-flight count is begun-minus-completed, matching the loading line beside it.
assert.match(
html,
/Unloads still in the pipeline<\/[a-z]+><[^>]*>3</,
'the unloading pipeline does not report begun-minus-completed',
);
// And a game with nothing in flight and nothing discarded says neither — `push` drops a zero.
const quiet = resultsHtml(withTally({ unloadsBegun: 2, unloadsCompleted: 2, cardsDiscarded: 0 }));
assert.ok(!quiet.includes('Unloads still in the pipeline'), 'an empty pipeline was reported as a fact');
assert.ok(!quiet.includes('Cards discarded'), 'a game with no discards claimed to have some');
});
it('names the winner the OFFICIAL result named, not whoever leads now (Gitea#11)', () => {
const f = finished({ mode: 'competitive', minCombinedRevenue: 0 }, ['Ada', 'Bo']);
// Ada won at the timetable; Bo overtakes during extended play. The frozen result stands.