233 lines
9.9 KiB
TypeScript
233 lines
9.9 KiB
TypeScript
/**
|
||
* 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,
|
||
});
|
||
});
|
||
});
|