/** * 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): Promise { 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, }); }); });