Files
station-master/test/session.test.ts
T

233 lines
9.9 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 } 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';
/**
* 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();
assert.equal(f.status, 'finished');
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('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,
});
});
});