The multiplayer set-up, the lobby, the start of a game, and four signals a remote client had never been sent. Reasoning, the preset table and what was verified how: CHANGELOG.md. - Co-op, Competitive, Cutthroat, Solitaire and Custom, on both screens, from one shared block — they had drifted, and each was missing a question the other asked. - A player reads the whole rule set before taking a seat, may leave a lobby or a running game, and keeps a seat across a reload. The host may clear a chair. The browser remembers every game it is in, not just the last one. - The start of a game is drawn: a handoff beat, an announcement, the code and type in the header. - Sound, the timetable flash, announcements and the just-drawn badge now reach a remote client; justDrawn goes to the seat that drew it and nobody else. - Played on StartOS, which found the rest: an Extra belongs to the player who played it, the board never named the Superintendent, bot seats were reported as absent players, and rule section numbers are out of every string a player reads. Also carries the previous session's Heavy Grade documentation work — asked again, answer unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016JczK5i33ZNSf2PtzZqdhS
415 lines
21 KiB
TypeScript
415 lines
21 KiB
TypeScript
/**
|
||
* 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');
|
||
});
|
||
});
|