Files
station-master/test/session.test.ts
T
Jesse.MarkowitzandClaude Opus 5 45580d8b61 v0.7.3 — a game that asks before it ends, and a results screen worth reading
Two issues off the tracker, and they are halves of one thing: the end of a game.
Neither ships on the 0.4.9 line — Jesse's call, that line may be complete and
these are not fixes people mid-playtest need.

EXTENDED PLAY (#11). The official result is settled at the original game length
and never changes: in a five-Day game extended to eight, the winner is whoever
led at the end of Day 5. Extending grants exactly one Day and the question is put
again at the end of it — solitaire the player decides alone, multiplayer it is
unanimous and one refusal ends it there. Only days-based endings offer it; a §3.4
collision breach is final, during an extended Day exactly as during the scheduled
game.

It could not be a client-side change. `check` refused every intent once `status`
left `active`; the server never loads a `finished` game back into memory; and a
save is `{ seed, config, history }` replayed through the engine, so a "continue"
the history does not record did not happen. Hence a fourth status,
`awaitingExtension`, and a `game.extend` intent. `config.days` never moves —
`extraDays` counts the borrowed Days and `official` freezes the outcome, the
standings and the statistics at the first ending.

THE RESULTS SCREEN (#16). `GAME OVER — revenueFloor` was `outcome.reason`, an
internal enum interpolated into the page at the one moment the game has the
player's whole attention. Every reason now has a sentence with the game's own
numbers in it. Around it: the result and winner, standings, the rules the game
was dealt under, a per-player breakdown, and the railroad — trains through the
Division and how many worked en route, loads made up and broken, passengers, cars
switched, trains destroyed. It shares the Day-end dialog's blocks rather than
reimplementing them, and stays reopenable so continuing does not cost you the
results.

Statistics are folded, not recorded: `state.tally` counts what the event stream
says happened, hooked at `applyIntent` and `advance` because `reduce` never sees
the phase driver's events — and those are the interesting ones. Nothing in the
rules reads it, and it rides the Frame, so multiplayer gets the same numbers as
solitaire from one implementation.

THREE BUGS FOUND IN TESTING, all of which would have shipped:

  - a saved game containing a vote could not be resumed (NO_ACTOR). A history is
    a flat Intent[] with no seat recorded; the replay derives who acted from the
    turn order, which cannot work for an intent every seat may send in any order.
    `game.extend` carries its voter, checked against the authenticated seat.
  - an all-bot game hung on the question for ever. `driveBots` loops on
    `currentActor`, null the moment the game stops, so it cannot cast a vote, and
    the bot-vote driver returned early with no humans to follow.
  - the balance harness became unbounded — `test/sim.test.ts` went from under a
    second to never finishing. `randomBot` took another Day about half the time,
    so every seeded game ran to playGame's 50,000-turn cap. Fixed in the driver,
    not in a policy, so it holds for bots not yet written.

All three have regression tests. 832 tests pass, against 793 before this change.

NOT BUILT, and a correction. #16's own comment said `trainStoodStill` "is emitted
per Stage, so a run of them is exactly the sat-on-a-siding streak". It is not:
reading advance.ts, it fires once per game and only for a train whose profile
sets `stopEarnsPoint` — the X18 Circus — with `stopPointClaimed` preventing a
second. The streak was built, rendered "1 Stage at (0,0)", and was taken out
again. There is no per-Stage "this train did not move" signal in the engine, so
"longest an engine sat on a siding" needs one first; TODO.md #36 records what it
would take, and the Circus set-up is reported instead. Badges remain the second
pass #16 asks for (TODO.md #33), and because the statistics are derived rather
than recorded, that pass can add any of them retroactively to games already
played and saved.

Extended play has not yet been played at a real table (TODO.md #35): the
multiplayer vote has only been driven through `session.intent`, never through two
browsers.

Closes #11
Closes #16

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
2026-08-29 04:23:26 -04:00

266 lines
12 KiB
TypeScript
Raw Permalink 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, overHandLimit, 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));
assert.equal(session.overHandLimit(), overHandLimit(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,
});
});
});