A real server existed since v0.5.0 but nobody could reach it without a hand-built ?seat=&secret= URL. This is what makes it a game you can actually create or join. The server now hosts more than one game: src/server/lobby.ts (new) is pure logic — creating, joining, bot seats, host transfer, starting — same split session.ts already draws for a running game. persistence.ts gained one directory per gameId plus a top-level index so index.ts resumes every saved game on boot. /api/stream and /api/intent now authenticate by session token instead of ?seat=&secret= — the token alone proves identity (lobby-and-sessions.md §1), so the join secret's job ends at the lobby door. Bots fill empty seats at Lobby.Start only, never take over a disconnected human (D8): session.ts gained driveBots(), playing developerBot forward through consecutive bot seats after every accepted intent. Disconnect keeps the seat and says so — Push gained an optional presence field, built entirely by http.ts and never routed through the engine, since a disconnect is transport news, not a GameEvent. Host rights pass to the earliest-joined remaining player if the host drops before start. Client: src/web/lobby.ts adds create/join forms and a live seating screen; localStorage replaces ?seat= for reconnecting straight back into a game already joined. A Multiplayer button sits beside New game; the New Game dialog itself is untouched. Found only by the live smoke test, not by typechecking: /api/intent read its token from the JSON body while the client sends it in the query string (matching /api/stream) — every intent failed "no such game" until caught by curl-level verification. Doc fix: multiplayer.md's D18 said the player cap was 6; lobby-and-sessions.md §2 says 2-4 with the reasoning and the test coverage to back it. The two had drifted apart. D18 now reads 2-4. Not verified: an actual browser walking through the lobby screens — none available in this environment, same limitation Phase 2's RemoteSession shipped under. 656 tests, 0 failures. tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this release.
237 lines
12 KiB
TypeScript
237 lines
12 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 { Push } from '../../src/server/session.ts';
|
|
import { createSession, resumeSession } from '../../src/server/session.ts';
|
|
|
|
const config: GameConfig = {
|
|
mode: 'competitive',
|
|
days: 5,
|
|
minCombinedRevenue: 0,
|
|
maxCollisionsPerDay: 0,
|
|
maxCollisionsTotal: 0,
|
|
pvpCardsAllowed: false,
|
|
optionalRules: {
|
|
reducedVisibility: false,
|
|
sisterTrains: 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');
|
|
});
|
|
});
|