v0.4.0 — multiplayer Phases 0 and 1: seat and player split apart, turn state per player, the page behind a Session, and eight seat/player mix-ups fixed with tests that fail without them
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user