A second-digit bump for a playtest read back against the save file. Nine questions were asked of one three-Day game; three were bugs, three were the rules working and undocumented, three were decisions. Every save on the test server was replayed against this build BEFORE release, which is how the cost of each rule was known before it was chosen rather than discovered after. EVERY DISTRICT OPENS ON A DEPOT. A Whistle Post has one A/D track and is not a Passenger Facility, so the opening of every game was spent unable to work a passenger and one arrival away from a collision. Two A/D tracks and passengers from Stage 1 now; "Players start with Whistle Posts, not Depots" is the harder game, set when the game is created. The deck follows the choice — starting on Depots the four Depot upgrade cards are left out, because an upgrade must be to the next tier and a Depot card at a table of Depots is a dead draw. How much easier it is showed up as a test failure rather than an argument: the cue-coverage pool needed widening from 24 seeded games to 60 before it held one collision. NO SAVE WAS STRANDED BY IT, which took care. This is the one house rule that changes how a game is DEALT rather than how it plays, so replaying a save under the wrong opening is a different railroad from intent one — silently, with no error. `withSavedOpening` fills it on the replay paths ONLY. Putting it in the resolver instead made a fresh Cutthroat game deal Whistle Posts and read as Custom, which is how the distinction was found. THREE BUGS, ALL REPORTED FROM ONE GAME AND ALL CONFIRMED ON ITS SAVE. An Office held TWO TRAINS ON ONE A/D TRACK. The capacity test passed with nothing standing, the train the Interlocking had been holding at the Limits was moved into the free slot, and the arriving train was pushed in after it without anyone asking again whether there was room — so the collision §8.3 calls for never happened. The held train keeps priority; the newcomer now takes the consequence it would have met had the held train arrived first. THE HISTORY FROZE, permanently, and the log cap was not really the cause. Each seat's "what have I sent you" bookmark was an INDEX into an array the game trims, so once a seat's bookmark reached the limit the slice returned nothing for the rest of the game — at a different moment per seat, because each holds its own. That game's log ended at exactly the cap. Lines carry a sequence number now, which survives trimming; proven by pushing twice the cap through a simulated seat. §8.1 ASKED THE WRONG QUESTION TWICE. "Trains may pass" returned `clear` before the Subdivision was looked at, so a train entering a Double Track was released however busy the rest of it was — that, not anything about Control Points, is what let Train 8 out with no ruling. And a train standing at an Office was invisible to the scan, so one about to re-enter the very Subdivision being entered counted for nothing. Capacity is the test, not presence: a Depot with a track free is not in the way; a Whistle Post with its one track taken is. THINGS THAT HAPPENED SILENTLY NOW SAY SO — a train held against a facing one, a train released from the Limits (a side effect of somebody else's arrival, so it simply appeared at the Office), and the train an Interlocking is holding, whose explanatory tooltip has existed since #99 with NO renderer ever reading the flag. WHERE A MOVE IS REFUSED, AND WHY. `exploreMoves` decides where the rails go and the pick-up restrictions are enforced afterwards in `check`, so a square the rails reached and the card forbade was reachable, un-offered, and absent from the block list with nothing said. Those squares are blocked with the rule that blocks them now, and the reasons are got by ASKING `check` rather than re-deriving: a second implementation of the rules is exactly the failure the block list exists to avoid. A train may also always recover its own caboose — X13 prints "may drop but not pick up anything", and a train needs its caboose to be made up, so one that parted with it could never legally leave again. RULES DECIDED IN SEPTEMBER AND APPLIED HERE. A Modifier must sit square against its host, no diagonals. A passenger Modifier may not be played at a Whistle Post. Both were built, measured, held back for a fortnight so a playtest could finish, and applied now. A Second Section costs its card: `SECOND_SECTION` was declared in content.ts and never dealt, so the action was free and the bot ordered 26 accidental ones in a measured round. The card is dealt and spent — gating on a card the deck never holds would have deleted the mechanic rather than fixed it. THE DOCUMENTATION IS A SET OF PAGES, not five text files served as text/plain — a card reference is mostly tables, and as plain text a table is rows of pipes. Markdown is still the one copy; the build renders it, and publishes the .md beside each page. No Markdown library: this project has no runtime dependencies and one would be a poor first. The pages add what Markdown cannot carry without drifting — a nav across the set, a contents list built from the headings actually rendered, an anchor on every heading, a 70-character measure, and tables that are tables. They print as ink on paper. The references caught up with the rules, checked rather than assumed: two statements had gone from stale to misleading (the Quickstart told a new player to "get a Depot down as soon as one appears"), and four rules nobody could look up are written down — the Office tier table, §8.1 in practice, what the Circus Train pays for, and that a Realignment can be a card with no legal target. Adding one card to the deck reshuffles every seeded deal, which broke five fixtures. Each was a seed meaning "a game like this" — TODO #84, exactly — so seeds moved and pools widened rather than assertions weakening, and the clearance fixture pins its terrain the way `enhancements.test.ts` already does. The three published replays were re-recorded. Closes TODO #40, #42a, #108, #109 and #110. 1046 fast tests and 35 sim tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
614 lines
32 KiB
TypeScript
614 lines
32 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,
|
||
// 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<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');
|
||
});
|
||
});
|