Files
station-master/test/server/session.test.ts
T
Jesse.MarkowitzandClaude Opus 5 d5445badcc v0.7.9.5 — two answers to one question, and the copy nobody read
Both faults are in what 0.7.9.4 had just built, and both are the same
shape: a second copy of an answer that agreed with the first until it
didn't.

#96 — the §3.3 vote has no actor, and the screen named one anyway. The
vote is PARALLEL: every un-voted seat may vote at any moment, in any
order, one refusal ends it, and `apply.ts` says where it accepts one that
there is no actor to be. The turn chart named the last seat to move
before the timetable ran out — no more claim on the vote than anybody
else — directly above a tally correctly showing three seats outstanding.

The cause is worth more than the symptom. `currentActor(game)`
(`web/game.ts`) guarded on `status !== 'active'`; `currentActorOfState`
(`sim/view.ts`), added the same day in #95 and the one the frame calls,
did not, so it handed back whatever `clock.currentActor` was left
holding. The view now carries the guard and `currentActor` delegates to
it. That matters more than the tidiness: `currentActor` is what REFUSES
an intent, so a screen answering differently tells the table to wait on a
player the server would turn away.

The fourth of this class after Gitea#21, #22 and #94 — but the first
found by asking a view helper its question in a state the game is not
`active` in, which is the generalisation and is cheaper than finding the
fifth the same way.

#97 — narration reaches a seat once, by one path. `Frame.lines` carried
the whole log on every push to every seat, and nothing read it:
`RemoteSession` accumulates from `push.lines` alone and its `lines()`
returns that accumulator, so the log was serialised into every frame,
grew all game, and was discarded on arrival while `linesSince` sent the
same text correctly beside it.

The duplicate was masking a bug rather than merely wasting bandwidth.
`connect()` cleared `lastFrame` but not `sentLines`, so a reconnecting
seat was told "nothing new since your last push" while the browser it
answered had just reloaded from an EMPTY accumulator — the history panel
came back blank, mid-game, with the server holding the whole log. So the
two halves are one change, and the plan's instruction taken alone ("stop
passing the full game log into `frameFor()`") would have deleted a real
behaviour rather than a duplicate.

Every remaining reader of `Frame.lines` was checked before the field was
emptied: all of them are the solitaire and replay path, which builds
Frames through `snapshot()` directly and never goes near a session.

One test was wrong before the code was. The first draft of the reconnect
test connected inside its own fixture, so both sides of the comparison
were the empty array and it passed against the broken server. Each test
now asserts its premise is non-empty before comparing.

Also: `docs/plans/jitsi-common-board.md` is committed. It was never added
— not ignored, just missed — while TODO.md cites it twice as the plan for
all of v0.8.0 and the last two releases were built from it, so a clone
got a TODO pointing at a file that did not exist.

917 tests pass, up from 909.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5boPxP6JHRYMm8adXaF5R
2026-09-07 20:35:19 -04:00

610 lines
32 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 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,
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<PlayerIndex, Push>([
[0 as PlayerIndex, session.connect(0 as PlayerIndex)],
[1 as PlayerIndex, session.connect(1 as PlayerIndex)],
]);
const seq = new Map<PlayerIndex, number>([[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<GameSession['exportSave']> => {
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<PlayerIndex, Push> } => {
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');
});
});