/** * The game session host (`src/server/session.ts`) — pure logic, no sockets, exercised directly. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import type { GameConfig, PlayerIndex } from '../../src/engine/state.ts'; import type { GameSession, Push } from '../../src/server/session.ts'; import { createSession, resumeSession, tryResumeSession } from '../../src/server/session.ts'; const config: GameConfig = { mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, // These fixtures were written against a Whistle Post opening — one A/D track and no // Control Point — and several of them test exactly that. Named explicitly since the // default became a Depot. houseRules: { startingOffice: 'whistlePost' }, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false, }, }; describe('the game session host', () => { it('gives a fresh connect a full Frame — nothing nulled', () => { const session = createSession(42, config, ['Alice', 'Bob']); const push = session.connect(0 as PlayerIndex); assert.notEqual(push.frame!.cells, null, 'a first connect nulled the board'); assert.notEqual(push.frame!.division, null, 'a first connect nulled the division'); }); it('only the current actor gets a real Menu; every other seat gets null', () => { const session = createSession(42, config, ['Alice', 'Bob']); const a = session.connect(0 as PlayerIndex); const b = session.connect(1 as PlayerIndex); const actorPushes = [a, b].filter((p) => p.menu !== null); assert.equal(actorPushes.length, 1, 'more than one seat (or zero) was given a real Menu'); }); it('rejects an intent from a seat that is not the current actor, with NOT_YOUR_TURN', () => { const session = createSession(42, config, ['Alice', 'Bob']); // Whichever seat is NOT the current actor should be refused, regardless of the intent's content. const bPush = session.connect(1 as PlayerIndex); const notActor = (bPush.menu === null ? 1 : 0) as PlayerIndex; const result = session.intent(notActor, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(result.accepted, false); if (!result.accepted) assert.equal(result.code, 'NOT_YOUR_TURN'); }); it('accepts a legal intent from the current actor and pushes every seat', () => { const session = createSession(42, config, ['Alice', 'Bob']); const aPush = session.connect(0 as PlayerIndex); const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex; const result = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(result.accepted, true); if (result.accepted) assert.equal(result.pushes.size, 2, 'not every connected seat was pushed to'); }); it('rejects an illegal intent with its real RejectionCode, and does not remember the seq', () => { const session = createSession(42, config, ['Alice', 'Bob']); const aPush = session.connect(0 as PlayerIndex); const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex; // Discarding before ever drawing is illegal on an opening hand at the limit — a safe "this is // definitely rejected" fixture that does not depend on exactly which cards were dealt. const bad = session.intent(actor, 1, { type: 'card.discard', cardId: 'not-a-real-card', toSlot: 0 }); assert.equal(bad.accepted, false); if (!bad.accepted) assert.ok(bad.code.length > 0, 'a rejection carried no code at all'); // The same seq, now with a legal intent, must still go through — a rejection is not "applied". const retry = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(retry.accepted, true, 'a seq burned by an earlier rejection could not be reused'); }); it('treats a repeated seq as an already-applied no-op, not a second application', () => { const session = createSession(42, config, ['Alice', 'Bob']); const aPush = session.connect(0 as PlayerIndex); const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex; const first = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(first.accepted, true); const second = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(second.accepted, true, 'a resend of an already-applied seq was rejected instead of ignored'); if (second.accepted) assert.equal(second.pushes.size, 0, 'a resend produced pushes as if newly applied'); }); it('deltas the board on a second push when it has not changed since the first', () => { const session = createSession(42, config, ['Alice', 'Bob']); const aPush = session.connect(0 as PlayerIndex); session.connect(1 as PlayerIndex); // both seats need a baseline Frame before a delta means anything const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex; const result = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(result.accepted, true); if (!result.accepted) return; // Choosing "draw" doesn't move a single card on the board — the division/cells should be nulled // on this push for a seat that already had them from `connect`. const push = result.pushes.get(actor)!; assert.equal(push.frame!.division, null, 'the board was resent even though nothing on it changed'); }); it('only sends narration NEW since the last push to that specific seat', () => { const session = createSession(42, config, ['Alice', 'Bob']); const aPush = session.connect(0 as PlayerIndex); const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex; // A first connect is this seat's first contact, so it gets everything narrated so far (the // "Game Begins" intro and whatever `drain()` said entering the first Stage) — not an empty log. assert.ok(aPush.lines.length > 0, 'a first connect got no narration at all, not even the game-begins intro'); const first = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(first.accepted, true); if (!first.accepted) return; const firstLines = first.pushes.get(actor)!.lines; assert.ok(firstLines.length > 0, 'the acting seat got no narration for its own action'); const second = session.intent(actor, 2, { type: 'draw.end' }); assert.equal(second.accepted, true); if (!second.accepted) return; const secondLines = second.pushes.get(actor)!.lines; assert.ok(secondLines.length > 0, 'a second real action produced no narration at all'); for (const line of firstLines) { assert.ok(!secondLines.includes(line), 'the second push repeated narration already sent in the first'); } }); }); describe('turn timings (lobby-and-sessions.md §5)', () => { it('records a wall-clock span once the acting player, phase, Day or Stage changes', () => { const session = createSession(42, config, ['Alice', 'Bob']); let pushes = new Map([ [0 as PlayerIndex, session.connect(0 as PlayerIndex)], [1 as PlayerIndex, session.connect(1 as PlayerIndex)], ]); const seq = new Map([[0 as PlayerIndex, 1], [1 as PlayerIndex, 1]]); const timings = []; for (let step = 0; step < 60 && timings.length === 0; step++) { const acting = [0, 1].find((seat) => pushes.get(seat as PlayerIndex)!.menu !== null) as PlayerIndex | undefined; const menu = acting === undefined ? null : pushes.get(acting)!.menu; if (acting === undefined || !menu || menu.options.length === 0) break; const n = seq.get(acting)!; seq.set(acting, n + 1); const result = session.intent(acting, n, menu.options[0]!); assert.equal(result.accepted, true, `step ${step}: ${JSON.stringify(menu.options[0])} rejected`); if (!result.accepted) break; if (result.timing) timings.push(result.timing); pushes = result.pushes; } assert.ok(timings.length > 0, 'no turn timing ever closed across 60 real steps of actual play'); const [timing] = timings; assert.ok(timing!.endedAt >= timing!.startedAt, 'a span ended before it started'); assert.ok([0, 1].includes(timing!.player), 'a timing named a player outside the table'); }); }); describe('bot seats (Phase 4 — D8, lobby-and-sessions.md §2)', () => { it('a bot never becomes the observable current actor — it plays before anyone can see it waiting', () => { // Seat 0 acts first (the opening Superintendent), so marking it a bot exercises `driveBots()` // at CONSTRUCTION time — before any external `intent()` has run at all. const session = createSession(11, config, ['Bot', 'Alice'], [0 as PlayerIndex]); assert.equal(session.isBot(0 as PlayerIndex), true); assert.equal(session.isBot(1 as PlayerIndex), false); const botPush = session.connect(0 as PlayerIndex); const humanPush = session.connect(1 as PlayerIndex); assert.equal(botPush.menu, null, 'the bot seat was handed a real decision to make'); assert.notEqual(humanPush.menu, null, 'nobody was left with a turn to take — the bot never played'); }); it('botSeats round-trips through exportSave/resumeSession', () => { const session = createSession(11, config, ['Bot', 'Alice'], [0 as PlayerIndex]); const resumed = resumeSession(session.exportSave()); assert.equal(resumed.isBot(0 as PlayerIndex), true); assert.equal(resumed.connect(0 as PlayerIndex).menu, null, 'a resumed bot seat still never gets a real decision'); }); it('a bot seat is driven forward after a human intent too, not only at construction', () => { // Two bots and one human: whichever of the two non-human seats comes up next after the human's // own move must be played automatically, with no external `intent()` for either of them. const session = createSession(11, config, ['Alice', 'Bot', 'Bot'], [1 as PlayerIndex, 2 as PlayerIndex]); const before = session.exportSave().history.length; const applied = session.intent(0 as PlayerIndex, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(applied.accepted, true); assert.equal(session.connect(1 as PlayerIndex).menu, null, 'bot seat 1 was left with a real decision'); assert.equal(session.connect(2 as PlayerIndex).menu, null, 'bot seat 2 was left with a real decision'); // Not a strict proof either bot actually moved (the human's own turn may not have ended yet), // but the history can only ever have grown, never shrunk, and never rejected mid-drive. assert.ok(session.exportSave().history.length >= before + 1); }); }); describe('persistence hooks — exportSave / resumeSession (Phase 3)', () => { it('exportSave carries enough to reconstruct the exact same game', () => { const session = createSession(42, config, ['Alice', 'Bob']); const push = session.connect(0 as PlayerIndex); const actor = (push.menu !== null ? 0 : 1) as PlayerIndex; const applied = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(applied.accepted, true); const saved = session.exportSave(); assert.equal(saved.seed, 42); assert.deepEqual(saved.playerNames, ['Alice', 'Bob']); assert.equal(saved.history.length, 1); assert.equal(saved.status, 'active'); assert.ok(saved.createdAt > 0, 'createdAt was not set'); const resumed = resumeSession(saved); const before = session.connect(actor); const after = resumed.connect(actor); assert.deepEqual(after.frame, before.frame, 'resumeSession did not reconstruct the same board/state'); }); it('a resumed session keeps enforcing whose turn it is', () => { const session = createSession(7, config, ['Alice', 'Bob']); const push = session.connect(0 as PlayerIndex); const actor = (push.menu !== null ? 0 : 1) as PlayerIndex; session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); const resumed = resumeSession(session.exportSave()); const notActor = (actor === 0 ? 1 : 0) as PlayerIndex; const rejected = resumed.intent(notActor, 1, { type: 'draw.end' }); assert.equal(rejected.accepted, false); if (!rejected.accepted) assert.equal(rejected.code, 'NOT_YOUR_TURN'); }); it('marks status finished only once the game actually is', () => { const session = createSession(42, config, ['Alice', 'Bob']); assert.equal(session.exportSave().status, 'active'); }); }); describe('summary() — what an administrator sees without replaying the game', () => { it('describes a fresh game: who is at the table, where it has got to, and who it waits on', () => { const session = createSession(42, config, ['Alice', 'Bob']); const s = session.summary(); assert.equal(s.playerCount, 2); assert.deepEqual(s.playerNames, ['Alice', 'Bob']); assert.equal(s.status, 'active'); assert.equal(s.day, 1); assert.equal(s.stage, 1); assert.equal(typeof s.phase, 'string'); assert.ok(s.waitingOn, 'a game in play must be waiting on somebody'); assert.equal(s.waitingOn!.name, s.playerNames[s.waitingOn!.seat]); }); it('does not hand back a copy of the history the way exportSave must', () => { // The health check polls this on a timer, so it answering with every intent of every game // would make a question about none of them cost a copy of all of them. const session = createSession(42, config, ['Alice', 'Bob']); assert.equal('history' in session.summary(), false); }); it('moves lastMoveAt when a move is accepted, and leaves it alone when one is refused', async () => { const session = createSession(42, config, ['Alice', 'Bob']); const created = session.summary(); assert.equal(created.lastMoveAt, created.createdAt, 'an untouched game has not moved since it began'); const actor = (session.connect(0 as PlayerIndex).menu !== null ? 0 : 1) as PlayerIndex; const idle = (1 - actor) as PlayerIndex; // A rejection is not a move — a player poking at a game they cannot act in must not make it // look alive to whoever is deciding whether it has stalled. session.intent(idle, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(session.summary().lastMoveAt, created.lastMoveAt, 'a refused intent moved the clock'); await new Promise((r) => setTimeout(r, 2)); const accepted = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(accepted.accepted, true); assert.ok(session.summary().lastMoveAt > created.lastMoveAt, 'an accepted intent did not move the clock'); }); it('carries lastMoveAt across a restart, and falls back to createdAt for a save without one', async () => { const session = createSession(42, config, ['Alice', 'Bob']); const actor = (session.connect(0 as PlayerIndex).menu !== null ? 0 : 1) as PlayerIndex; await new Promise((r) => setTimeout(r, 2)); session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); const saved = session.exportSave(); assert.equal(resumeSession(saved).summary().lastMoveAt, saved.lastMoveAt); // A game written before the field existed still has to load, and reads as untouched since it // began rather than as having just moved. const { lastMoveAt: _dropped, ...older } = saved; const revived = resumeSession(older).summary(); assert.equal(revived.lastMoveAt, saved.createdAt); }); it('reports a finished game as waiting on nobody', () => { // Every seat a bot, so the game plays itself to a finish inside the constructor. const session = createSession(4242, config, ['A', 'B'], [0 as PlayerIndex, 1 as PlayerIndex]); const s = session.summary(); assert.equal(s.status, 'finished'); assert.equal(s.waitingOn, null, 'a finished game must not name somebody to wait for'); }); }); describe('a save survives a release that did not change the rules', () => { /** Plays a couple of real moves so the history is worth replaying. */ const played = (): ReturnType => { const s = createSession(42, config, ['Alice', 'Bob']); const actor = (s.connect(0 as PlayerIndex).menu !== null ? 0 : 1) as PlayerIndex; s.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); return s.exportSave(); }; it('resumes whatever version stamped it, so long as the moves still replay', () => { // This is the whole point. The engine version used to gate this, and it is the PACKAGE version // — it moves for a CSS fix. Four releases in a row destroyed every game in progress, one of // them for a change that only altered how the board is drawn. const saved = played(); const r = tryResumeSession(saved); assert.equal(r.ok, true, 'a replayable save was refused'); if (!r.ok) return; assert.deepEqual(r.session.exportSave().history, saved.history); }); it('refuses a save whose moves no longer replay, and says which move and why', () => { // A rules change is simulated by corrupting one intent — the engine cannot apply it, which is // exactly the shape a genuinely incompatible save has. const saved = played(); const broken = { ...saved, history: [...saved.history, { type: 'localOps.choose', option: 'not-a-real-option' } as never], }; const r = tryResumeSession(broken); assert.equal(r.ok, false, 'a save the rules reject was accepted'); if (r.ok) return; assert.equal(r.failure.of, broken.history.length); assert.equal(r.failure.stoppedAt, broken.history.length - 1, 'wrong move blamed'); assert.equal(r.failure.intent, 'localOps.choose'); assert.ok(r.failure.code.length > 0, 'no rejection code to act on'); }); it('never silently truncates — the old loop stopped at a bad move and said nothing', () => { // The silence was survivable only because the version check meant a doomed replay was never // attempted. Now that the replay IS the check, a partial one must be impossible to mistake for // a whole one. const saved = played(); const broken = { ...saved, history: [{ type: 'draw.end' } as never, ...saved.history] }; const r = tryResumeSession(broken); assert.equal(r.ok, false, 'a truncated replay was returned as a healthy session'); }); }); describe('the four transient signals (2026-08-23)', () => { /** * Multiplayer had none of these: `createRemoteSession` answered every one of them with an empty * value, so a game on a server had no sound, no timetable flash, no announcement when a completed * run paid the table, and no badge on the card you had just drawn. They ride on the push now — and * `justDrawn` is the one that has to be careful, because `game.justDrawn` is ONE field for the * whole game and does not say whose card it is. */ /** Drives the game until the current actor draws a card, and returns that turn's pushes. */ const drawSomething = (session: GameSession, players: number): { seat: PlayerIndex; pushes: Map } => { for (let seat = 0 as PlayerIndex; seat < players; seat++) { if (session.connect(seat).menu === null) continue; // Two steps: §6's three options are exclusive, so the turn is spent on drawing before a card // actually leaves the deck. const chose = session.intent(seat, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(chose.accepted, true, 'the actor could not choose to draw'); const result = session.intent(seat, 2, { type: 'draw.fromHomeOffice' }); assert.equal(result.accepted, true, 'the actor could not draw from the Home Office deck'); if (!result.accepted) throw new Error('unreachable'); return { seat, pushes: result.pushes }; } throw new Error('no seat was able to act'); }; it('sends the drawn card to the seat that drew it, and to nobody else', () => { const session = createSession(42, config, ['Alice', 'Bob', 'Carol']); const { seat, pushes } = drawSomething(session, 3); const mine = pushes.get(seat); assert.ok(mine?.justDrawn, 'the drawing seat was not told which card it drew'); for (const [other, push] of pushes) { if (other === seat) continue; assert.equal( push.justDrawn, undefined, `seat ${other} was told which card seat ${seat} drew — that is a hand leak`, ); } }); it('keeps the badge across a reconnect, still only for its owner', () => { const session = createSession(42, config, ['Alice', 'Bob', 'Carol']); const { seat, pushes } = drawSomething(session, 3); const drawn = pushes.get(seat)?.justDrawn; assert.equal(session.connect(seat).justDrawn, drawn, 'a refresh lost the card the player just drew'); const other = ((seat + 1) % 3) as PlayerIndex; assert.equal(session.connect(other).justDrawn, undefined, 'a reconnecting seat was told about someone else’s draw'); }); it('sends the shared signals to every seat, identically, and drains them', () => { // A collision anywhere on the Division, the Stage bell, a train running off the end and paying // everyone: these are the table's, not one player's. const session = createSession(42, config, ['Alice', 'Bob', 'Carol']); const { pushes } = drawSomething(session, 3); const cues = [...pushes.values()].map((p) => JSON.stringify(p.cues ?? [])); assert.equal(new Set(cues).size, 1, 'seats were sent different sound cues for the same events'); // Drained: a signal marks a moment, so a later connect must not replay it. const later = session.connect(0 as PlayerIndex); assert.equal(later.cues, undefined, 'a reconnecting client was sent the sounds of what it missed'); assert.equal(later.announcement, undefined, 'a reconnecting client was re-sent an old announcement'); assert.equal(later.scheduled, undefined, 'a reconnecting client was re-sent an old timetable flash'); }); }); // --------------------------------------------------------------------------- describe('§3.3 extended play across the server (Gitea#11)', () => { /** * A one-Day game, so these tests reach the end of the timetable by actually PLAYING to it. * * There is no back door into a session's engine state and there should not be — `connect`, * `intent`, `exportSave` and `summary` are the whole surface. So the clock is run down through the * same calls a client makes, which has the side benefit of exercising the real path: what is under * test here is the session's handling of the vote (the turn guard, the bots, the saved status), * and reaching it any other way would prove less. */ const oneDay: GameConfig = { ...config, days: 1 }; /** What seat `seat` can currently see. `connect` always yields a full Frame, never a delta. */ const frameOf = (session: GameSession, seat: PlayerIndex) => session.connect(seat).frame!; /** Plays until the game stops asking for ordinary moves. Returns the Frame it stopped on. */ function playToTheEnd(session: GameSession, seats: PlayerIndex[]) { let seq = 0; for (let i = 0; i < 5_000; i++) { const acting = seats.find((s) => session.connect(s).menu !== null); if (acting === undefined) break; const menu = session.connect(acting).menu!; if (menu.options.length === 0) break; if (!session.intent(acting, seq++, menu.options[0]!).accepted) break; } return { frame: frameOf(session, seats[0]!), seq }; } it('stops to ask rather than ending, and every seat can see the question', () => { const session = createSession(42, oneDay, ['Alice', 'Bob']); const { frame } = playToTheEnd(session, [0, 1] as PlayerIndex[]); assert.equal(frame.status, 'awaitingExtension', 'the game did not stop to ask'); assert.ok(frame.official, 'the official result did not reach the client'); assert.deepEqual(frame.extensionVotes, [null, null], 'the votes did not reach the client'); }); it('accepts the vote from a seat that is not the current actor', () => { // `currentActor` is null once the game has stopped, so the ordinary turn guard would refuse // every vote with NOT_YOUR_TURN. Both seats vote here and neither of them is the actor. const session = createSession(42, oneDay, ['Alice', 'Bob']); const { seq } = playToTheEnd(session, [0, 1] as PlayerIndex[]); const a = session.intent(0 as PlayerIndex, seq + 1, { type: 'game.extend', player: 0, agree: true }); assert.equal(a.accepted, true, 'seat 0 could not vote'); const b = session.intent(1 as PlayerIndex, seq + 2, { type: 'game.extend', player: 1, agree: true }); assert.equal(b.accepted, true, 'seat 1 could not vote'); const after = frameOf(session, 0 as PlayerIndex); assert.equal(after.extraDays, 1, 'a unanimous table was not given its Day'); assert.equal(after.status, 'active', 'play did not resume'); }); it('bots agree only once every human has, and never lead', () => { // "Bots will not disagree with the human. Humans get to vote first" (Jesse, 2026-08-28). const session = createSession(42, oneDay, ['Alice', 'Botty'], [1 as PlayerIndex]); const { seq } = playToTheEnd(session, [0, 1] as PlayerIndex[]); assert.equal(frameOf(session, 0 as PlayerIndex).status, 'awaitingExtension'); assert.equal( frameOf(session, 0 as PlayerIndex).extensionVotes[1], null, 'the bot voted before the human did', ); session.intent(0 as PlayerIndex, seq + 1, { type: 'game.extend', player: 0, agree: true }); const after = frameOf(session, 0 as PlayerIndex); assert.equal(after.extraDays, 1, 'the bot did not follow the human into another Day'); assert.equal(after.status, 'active'); }); it('a human refusal ends it, and no bot overrides that', () => { const session = createSession(42, oneDay, ['Alice', 'Botty'], [1 as PlayerIndex]); const { seq } = playToTheEnd(session, [0, 1] as PlayerIndex[]); session.intent(0 as PlayerIndex, seq + 1, { type: 'game.extend', player: 0, agree: false }); const after = frameOf(session, 0 as PlayerIndex); assert.equal(after.status, 'finished'); assert.equal(after.extraDays, 0); }); it('REGRESSION: an all-bot game ends rather than hanging on the question', () => { /** * `driveBots` loops on `currentActor`, which is null the moment the game stops to ask — so it * cannot cast the vote itself, and the bots' vote is driven separately. The first cut of that * driver returned early when there were no humans to follow, on the reasoning that a bot-only * table would decline through the ordinary path. It has no ordinary path: nothing ever asked * the bots, and an all-bot session sat on the question for ever without reaching `finished`. * Caught by `summary()`'s own "a finished game waits on nobody" test. */ const session = createSession(4242, oneDay, ['A', 'B'], [0, 1] as PlayerIndex[]); assert.equal(frameOf(session, 0 as PlayerIndex).status, 'finished', 'the bots never answered'); assert.equal(session.summary().waitingOn, null); assert.equal(frameOf(session, 0 as PlayerIndex).extraDays, 0, 'bots voted themselves another Day'); }); it('saves a game awaiting its vote as ACTIVE, so a restart resumes it', () => { // `server/index.ts` never loads a `finished` game back into memory. A game paused on the // extension question is waiting on its table, not over — persisting it as finished would strand // it on disk mid-decision. const session = createSession(42, oneDay, ['Alice', 'Bob']); playToTheEnd(session, [0, 1] as PlayerIndex[]); assert.equal(frameOf(session, 0 as PlayerIndex).status, 'awaitingExtension'); assert.equal(session.exportSave().status, 'active', 'a paused game was saved as finished'); assert.equal(session.summary().status, 'active'); }); it('resumes a paused game from its history, vote and all', () => { const session = createSession(42, oneDay, ['Alice', 'Bob']); const { seq } = playToTheEnd(session, [0, 1] as PlayerIndex[]); session.intent(0 as PlayerIndex, seq + 1, { type: 'game.extend', player: 0, agree: true }); const resumed = resumeSession(session.exportSave()); const a = frameOf(session, 0 as PlayerIndex); const b = frameOf(resumed, 0 as PlayerIndex); assert.deepEqual(b.extensionVotes, a.extensionVotes, 'the votes did not survive the replay'); assert.equal(b.extraDays, a.extraDays, 'the extra Day did not survive the replay'); assert.deepEqual(b.official, a.official, 'the official result did not survive the replay'); }); }); /** * NARRATION HAS ONE PATH, AND A RECONNECT HAS TO GET ALL OF IT (#97, Gitea#20 step 1). * * The common-board plan asks for one thing here: "stop passing the full game log into `frameFor()`; * continue sending sanitized incremental narration through `Push.lines`." Doing only the first half * would have deleted a real behaviour, so this pins the pair. * * WHAT WAS ACTUALLY WRONG. `Frame.lines` carried the WHOLE log on every push, and nothing read it: * `RemoteSession` (`web/session.ts`) accumulates `lines` from `push.lines` alone and its `lines()` * returns that accumulator. So the log was serialised into every frame for every seat, grew all * game, and was thrown away on arrival — while `linesSince` sent the same text again, correctly, * beside it. * * And the duplicate was masking a bug rather than merely wasting bandwidth. `connect()` clears * `lastFrame` but did NOT clear `sentLines`, so a reconnecting seat was told "nothing new since your * last push" — while the browser it was answering had just reloaded and started from an EMPTY * accumulator. The history panel came back blank after a refresh, mid-game, with the server holding * the whole log and shipping it in the one field nobody reads. * * So the two halves are one change: a (re)connect resets the seat's watermark and `Push.lines` on a * connect IS the history, which is what lets the frame stop carrying a second copy. */ describe('narration reaches a seat exactly once, by one path (#97)', () => { /** * A session with narration already in the log and NO connect yet, so a first connect is a real * "catch me up" rather than a no-op. Connecting inside this helper is what made the first draft of * the reconnect test pass vacuously: both sides of the comparison were the empty array. */ const played = (): GameSession => createSession(550943578, config, ['Alice', 'Bob']); it('a FIRST connect carries the narration so far in Push.lines', () => { const session = createSession(550943578, config, ['Alice', 'Bob']); const push = session.connect(0 as PlayerIndex); assert.ok(push.lines.length > 0, 'a first connect was given no narration at all'); assert.ok( push.lines.some((l) => /players|competitive/i.test(l.text)), 'the opening lines are not in what a first connect received', ); }); it('a RECONNECT is given the whole log again, because the browser it answers has none', () => { const session = played(); const first = session.connect(0 as PlayerIndex); const again = session.connect(0 as PlayerIndex); // NON-EMPTY first: two empty arrays are deepEqual, and asserting only that is how this test // passed against the broken code on its first draft. assert.ok(first.lines.length > 0, 'the premise is gone: there was no narration to be given'); assert.deepEqual( again.lines, first.lines, 'a reconnecting seat was told nothing was new, and its history panel would come back empty', ); }); it('the Frame does NOT carry a second copy of the log', () => { const session = played(); const push = session.connect(0 as PlayerIndex); assert.deepEqual( (push.frame as unknown as { lines: unknown[] }).lines, [], 'the whole narration log is still being serialised into every Frame, where nothing reads it', ); }); it('an ordinary push after a connect carries only what is NEW', () => { const session = played(); const opening = session.connect(0 as PlayerIndex); assert.ok(opening.lines.length > 0); // A second connect for the OTHER seat must not re-send seat 0 anything. const other = session.connect(1 as PlayerIndex); assert.ok(other.lines.length > 0, 'the other seat got no history of its own'); const third = session.connect(0 as PlayerIndex); assert.deepEqual(third.lines, opening.lines, 'a reconnect is the full log, every time'); }); }); describe('the intent sequence across a reconnect (v0.8.4)', () => { it('tells a connecting seat the last seq it had accepted, so a reloaded page continues the count', () => { const session = createSession(42, config, ['Alice', 'Bob']); const first = session.connect(0 as PlayerIndex); assert.equal(first.lastSeq, 0, 'a seat that has moved nothing should be told 0'); const actor = (first.menu !== null ? 0 : 1) as PlayerIndex; const r = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); assert.ok(r.accepted); // A reload: the client starts its own count from 1 again unless told otherwise. const again = session.connect(actor); assert.equal(again.lastSeq, 1, 'the reconnect push does not say where the count stands'); // The repeat the old client would have sent — silently swallowed as a resend. const repeat = session.intent(actor, 1, { type: 'draw.fromHomeOffice' }); assert.ok(repeat.accepted && repeat.pushes.size === 0, 'seq 1 should still read as an idempotent resend'); // Continuing from lastSeq + 1 is a real move. const next = session.intent(actor, 2, { type: 'draw.fromHomeOffice' }); assert.ok(next.accepted && next.pushes.size > 0, 'seq 2 was not applied'); }); });