Files
station-master/test/session.test.ts
T
Jesse.MarkowitzandClaude Opus 5 7f4e027258 v0.7.9.6 — three things the engine knew and the screen did not
Found by looking rather than by being told. Gitea#21, #22, #94 and #96
were four instances of one fault in a row — the engine gains something
that changes what a train may do, and nothing draws it — and every one
was found by a player hitting it. So instead of waiting for the fifth,
every field of GameState and its nested types was enumerated, checked for
a reader in sim/view.ts, src/web/ and sim/narrate.ts, and the survivors
verified BY RUNNING THE ENGINE rather than by trusting the grep.

Four fields had no reader. `movedThisPhase` lives and dies inside one
`advance` call and is nobody's business. The other three are below. What
was ruled out matters as much: `freightWorked`, `drawnThisTurn`,
`freightAgentUsed`, `switchedSince` and `movesUsed` are invisible on
purpose, their effect already showing as legality or as a complement
already on the Frame. A field is not a display gap merely because nothing
renders it.

#98 — the Crew Tray pool. §7 scarcity is called an explicit mechanic and
was explicit only in the engine. The blocked panel had one tray rule,
keyed off the train due out this Stage, so a player who spent a card on
an Extra or ordered a second section got an EMPTY panel while their train
sat behind an exhausted pool — both having been announced once in the log
in a line promising a future event that nothing then confirmed. The
shared table carries the pool and the queue now, so the common board gets
it too, and the panel reports all three with the count beside them.

#99 — a train held at the Limits vanished off the board, and this one had
shipped. The Interlocking stops an inbound train on the Limit Track
rather than colliding with a full Office. `arriveAtOffice` removes the
tray from the Mainline node's `transits` and the Interlocking branch
pushes it onto `heldAtLimits` without assigning `tray.position` — and the
map draws mainline nodes from `transits` and squares from
`position.at === 'grid'`, so between the two it was drawn in NEITHER. It
disappeared on arrival and reappeared in the Office some Stages later.
Fixed in the view: the engine is right, and `position` is left alone
deliberately so nothing treats the train as standing somewhere it could
be switched from.

#100 — the Campaign Train's speeches change its rules, and the card said
the same thing before and after. Worse, the "EXPEDITED ... costs 1
Revenue" warning prints only under `rules.expedite`, so X17 became
subject to a fault whose warning the game shows to every other expedited
train and never to it. `trainRules` reads `speechMade` now and borrows
`isExpedited` from advance.ts rather than restating the test.

#45 — the 0.7.9 dead-field audit, finished, and the answer was different
for each. `overHandLimit` is WIRED: its consumer existed all along and
was inferring the hand limit from the ABSENCE of `draw.end` in the menu,
which is sound only while `check` keeps refusing for exactly three
reasons. `viewerSeat` is DOCUMENTED, with a condition — Gitea#20's board
keys districts by seat, and the note says to delete it if step 2 ships
without using it.

The audit had missed a third limb. `game.mustPlayCard` was assigned on
every submit and read by nothing: deleted. Chasing it turned up the thing
worth fixing — the §6.2 hand-limit test existed in THREE places, all
agreeing, which is the state #96's disagreement started from. One
`overHandLimit(state, player)` in state.ts now, and the other two ask it.
`Session.overHandLimit()` is deleted rather than kept: the Frame already
carries the fact, so the method was a second path to it.

934 tests pass, up from 917. The 17 new ones were written red, and each
fix checked by mutation: reverting `speechMade` fails 2, dropping the
held-train projection fails 4, forgetting the tray queues fails 2. The
empty blocked panel is reported beside its positive control, since an
empty result from a broken function proves nothing.

NOT VERIFIED AT A TABLE. Engine and view work, checked by tests and by
running the engine. #39 and #35 still stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5boPxP6JHRYMm8adXaF5R
2026-09-07 21:29:16 -04:00

265 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The Session boundary.
*
* Phase 1 of `docs/architecture/multiplayer.md` moved the page off `game.ts` and onto a `Session`,
* so that a server can later be substituted for the local engine without the page noticing. The
* whole point of the change is that nothing about solitaire changed, which is a hard thing to prove
* by playing — hence this file: the same seed driven the same way through both routes must land in
* the same position, card for card.
*
* The rest of the suite covers what a remote session will have to reproduce exactly: which calls
* fire the redraw, which signals drain, and what `capabilities` admits a local session can do that a
* server cannot.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { actionGroups, currentActor, handPlayable, newGame, submit, toSave, view } from '../src/web/game.ts';
import { createLocalSession } from '../src/web/session.ts';
import { seatLabel } from '../src/sim/view.ts';
/**
* Drive a session by always taking the first offered action.
*
* `menu().options` and `actionGroups(game).options` are the same `legalActions` list in the same
* order, so taking index 0 on either side is the same rule — which is what makes the two routes
* comparable below.
*/
async function playSession(seed: number, maxTurns = 20_000) {
const session = createLocalSession(seed);
let turns = 0;
for (; turns < maxTurns; turns++) {
if (session.actor() === null) break;
const options = session.menu().options;
if (options.length === 0) break;
if (!(await session.submit(options[0]!))) break;
}
return { session, turns };
}
/** One action through the session, first option, for tests that just need the game to move. */
async function step(session: ReturnType<typeof createLocalSession>): Promise<boolean> {
const options = session.menu().options;
if (options.length === 0) return false;
return session.submit(options[0]!);
}
describe('a local session plays the same game as the calls it replaced', () => {
it('reaches an identical position from the same seed', async () => {
// The equivalence proof. `game.ts` directly on the left, the Session on the right, same seed and
// same tie-breaking rule — if the boundary leaked anything the boards diverge.
const game = newGame(77);
for (let i = 0; i < 20_000; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
if (!submit(game, options[0]!)) break;
}
const { session, turns } = await playSession(77);
assert.ok(turns > 50, `only ${turns} decisions — the game stalled`);
const f = session.view();
/**
* `awaitingExtension` since Gitea#11, not `finished`: both loops stop when there is no actor,
* and a days-based ending now parks the game on the "play one more Day?" question rather than
* ending it outright. What this test is actually about is unchanged — the two sides reach the
* SAME position — and the assertion below is still the one carrying that.
*/
assert.equal(f.status, 'awaitingExtension');
assert.equal(f.status, view(game).status);
assert.equal(f.day, view(game).day);
assert.deepEqual(f.cells, view(game).cells, 'the board differs across the boundary');
assert.deepEqual(session.save(), toSave(game), 'the histories differ across the boundary');
});
it('reports the same seat, actor and hand as the underlying game', () => {
const session = createLocalSession(31);
assert.equal(session.seat(), 0);
assert.equal(session.actor(), currentActor(session.game));
assert.deepEqual(session.handPlayable(), handPlayable(session.game));
});
});
describe('a session tells the page when to redraw', () => {
it('notifies on an accepted intent and not on a refused one', async () => {
const session = createLocalSession(404);
let redraws = 0;
session.subscribe(() => {
redraws++;
});
assert.ok(await step(session));
assert.equal(redraws, 1);
// A refused intent leaves the game where it was, so there is nothing to redraw. This matters
// more than it looks: a remote session will push on state change, and a page that redrew on
// every submit would flicker on every rejection the server sends back.
assert.equal(await session.submit({ type: 'turn.end', player: 0 } as never), false);
assert.equal(redraws, 1);
});
it('stops notifying after unsubscribe', async () => {
const session = createLocalSession(404);
let redraws = 0;
const off = session.subscribe(() => {
redraws++;
});
assert.ok(await step(session));
off();
await step(session);
assert.equal(redraws, 1);
});
});
describe('the transient signals drain', () => {
it('hands out cues once', async () => {
// Cues are a moment, not a state — the Frame can be rebuilt any number of times per render, and
// a sound that replayed on each rebuild would stutter. So they are taken, not read.
const session = createLocalSession(88);
for (let i = 0; i < 40; i++) {
if (session.actor() === null) break;
if (!(await step(session))) break;
if (session.takeCues().length > 0) {
assert.deepEqual(session.takeCues(), [], 'a cue was handed out twice');
return;
}
}
assert.fail('no cue was earned in 40 actions — the driver never reached a sounding event');
});
it('hands out a scheduled slot once', () => {
const session = createLocalSession(88);
session.game.scheduled = 3;
assert.equal(session.takeScheduled(), 3);
assert.equal(session.takeScheduled(), null, 'the timetable flash fired twice');
});
it('keeps the newest card badged until another draw replaces it', () => {
// Unlike the other two this one PERSISTS: it says which card is new, not that something just
// happened, so it survives redraws and is superseded rather than consumed.
const session = createLocalSession(88);
session.game.justDrawn = 'card-a';
assert.equal(session.justDrawn(), 'card-a');
assert.equal(session.justDrawn(), 'card-a');
});
});
describe('undo and restore rebuild the game without leaking the replay', () => {
it('steps back one action and refuses at the start', async () => {
const session = createLocalSession(909);
assert.equal(session.steps(), 0);
assert.equal(session.undo(), false, 'undo at the start must be a no-op');
// Cloned: `toSave` hands back the game's own history array, so holding the object would watch it
// grow rather than record where the game was.
const before = structuredClone(session.save());
await step(session);
assert.equal(session.steps(), 1);
assert.equal(session.undo(), true);
assert.equal(session.steps(), 0);
assert.deepEqual(session.save(), before, 'undo did not return to the previous position');
});
it('drops the rebuilt game’s cues and draws', async () => {
// Undo replays the history from the start, which re-earns every cue and re-records every draw
// along the way. None of that is news to a player who just stepped back, so a page that read it
// would replay a whole game of sounds and badge whichever card the replay ended on.
const session = createLocalSession(909);
for (let i = 0; i < 12; i++) {
if (session.actor() === null) break;
if (!(await step(session))) break;
}
session.takeCues();
assert.equal(session.undo(), true);
assert.deepEqual(session.takeCues(), [], 'undo replayed the game’s sounds');
assert.equal(session.takeScheduled(), null);
assert.equal(session.justDrawn(), null, 'undo badged a card from the replay');
});
it('restores to the same position with nothing badged', async () => {
const session = createLocalSession(909);
for (let i = 0; i < 30; i++) {
if (session.actor() === null) break;
if (!(await step(session))) break;
}
const save = session.save();
const cells = session.view().cells;
const fresh = createLocalSession(1);
fresh.restore(save);
assert.equal(fresh.seed(), save.seed, 'restore kept the session’s original seed');
assert.equal(fresh.steps(), save.history.length);
assert.deepEqual(fresh.view().cells, cells, 'the board differs after restore');
assert.equal(fresh.justDrawn(), null, 'restore badged a card from the replay');
});
});
describe('the page stays on the near side of the boundary', () => {
const main = readFileSync(join(import.meta.dirname, '..', 'src', 'web', 'main.ts'), 'utf8');
it('never reaches through to GameState', () => {
// There were eleven of these, and each one was a place the page knew something a server would
// never send it — the deck order, another player's hand, the RNG. They all had to go before a
// `RemoteSession` could be dropped in, and the cheapest way to keep them gone is to say so here:
// a reader that comes back is a failing test rather than a bug found in Phase 2.
const reaches = main.match(/\b(session\.game|game)\.state\b/g) ?? [];
assert.deepEqual(reaches, [], 'main.ts reached through the Session into GameState');
});
it('imports only types from game.ts', () => {
// Everything the page DOES now goes through `session`. Values from `game.ts` — `submit`, `view`,
// `undo` — are the local engine by another name, and importing one is how the boundary gets
// quietly reopened. Types are fine: `Frame` and `Menu` are what a server sends.
const lines = main.split('\n').filter((l) => l.includes("from './game.ts'"));
assert.ok(lines.length > 0, 'the check found no game.ts import at all — it has stopped checking');
for (const l of lines) {
assert.match(l, /^import type /, `main.ts imports values from game.ts: ${l.trim()}`);
}
});
});
describe('seats are counted from 1 wherever a person reads them', () => {
it('seatLabel shifts the zero-based index the whole engine uses', () => {
assert.deepEqual([0, 1, 2, 3].map(seatLabel), [1, 2, 3, 4]);
});
it('no user-facing "Seat N" bypasses it', () => {
// The internal convention is zero-based and must stay that way — it indexes `seating`, the
// seats array and every route. The DISPLAYED number is the one a player would say out loud, so
// the two have to be converted at exactly one place; anything interpolating a raw seat into a
// "Seat …" string has quietly reintroduced "Seat 0".
const roots = ['src/web', 'src/sim', 'src/server'];
const offenders: string[] = [];
for (const dir of roots) {
const base = join(import.meta.dirname, '..', dir);
for (const name of readdirSync(base, { recursive: true, encoding: 'utf8' })) {
if (!name.endsWith('.ts')) continue;
const text = readFileSync(join(base, name), 'utf8');
for (const m of text.matchAll(/`[^`]*Seat \$\{([^}]*)\}/g)) {
if (!m[1]!.includes('seatLabel')) offenders.push(`${dir}/${name}: ${m[0]!.slice(0, 60)}`);
}
}
}
assert.deepEqual(offenders, [], 'a seat is shown to a player without going through seatLabel');
});
});
describe('capabilities say what only a local session can do', () => {
it('offers undo, a local save and a new deal', () => {
// The page hides these rather than calling them and failing. A server can offer none of them: it
// cannot un-see what other players have already seen, it is itself the store, and dealing is the
// lobby's job. The page reads this object instead of assuming it is local.
assert.deepEqual(createLocalSession(1).capabilities, {
undo: true,
saveLocal: true,
newGame: true,
});
});
});