The third release from the audit; nothing a player sees changes. CHANGELOG has the detail. The 0.4.9 playtest line is no longer maintained (Jesse, 2026-09-29): the deploy rule that existed for it is gone and #85 is moot. The table test (#39 #35 #42a #40) is closed — every line of the checklist was met at a table. #46 is done and cannot regrow: the 36 unused declarations are removed and `noUnusedLocals`/`noUnusedParameters` are on; two of them were dead bot functions from rejected candidates the round said it had deleted. The documents no longer teach `trainCapSlack` (a knob that throws), point at `as-built.md` (deleted in 0.8.2), model `officeType` (the engine says `tier`) or describe `collisionOccurred` (never emitted); the README's account of bot flags now matches the bot's. Five playtest saves committed in `docs/` against the repository's own rule are in the ignored `playtests/`. What the audit found and did not fix is written down as TODO #112-#117, each with its reason. #112 is `docs/plans/structure.md`, the proposal for `http.ts`, `main.ts` and `check`. #117 — `/api/save` hands a seat the seed mid-game — waits on a conversation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
351 lines
15 KiB
TypeScript
351 lines
15 KiB
TypeScript
/**
|
||
* The lobby (`src/server/lobby.ts`) — pure logic, no sockets, no filesystem, exercised directly.
|
||
*/
|
||
|
||
import { describe, it } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
import type { GameConfig } from '../../src/engine/state.ts';
|
||
import {
|
||
createLobby,
|
||
freshGameCode,
|
||
joinLobby,
|
||
leaveLobby,
|
||
playerCountAllowed,
|
||
reassignHost,
|
||
setBotSeat,
|
||
startLobby,
|
||
} from '../../src/server/lobby.ts';
|
||
|
||
const competitive: 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,
|
||
},
|
||
};
|
||
const solitaire: GameConfig = { ...competitive, mode: 'solitaire' };
|
||
|
||
describe('creating and joining', () => {
|
||
it('seats the host at 0 and lays out the whole table at once', () => {
|
||
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0001', 3);
|
||
assert.equal(session.player, 0);
|
||
assert.equal(lobby.hostToken, session.token);
|
||
// The table is its full size immediately — the empty chairs exist and are waiting, rather
|
||
// than being appended as people arrive.
|
||
assert.equal(lobby.seats.length, 3);
|
||
assert.deepEqual(lobby.seats[0], { kind: 'human', token: session.token, displayName: 'Alice' });
|
||
assert.deepEqual(lobby.seats.slice(1), [null, null]);
|
||
assert.deepEqual(lobby.joinOrder, [session.token]);
|
||
});
|
||
|
||
it('fills the next empty seat, in order', () => {
|
||
const { lobby: l1, session: s1 } = createLobby(competitive, 'Alice', 'RAIL-0001', 4);
|
||
const j2 = joinLobby(l1, 'Bob');
|
||
assert.ok(j2.ok);
|
||
if (!j2.ok) return;
|
||
assert.equal(j2.session.player, 1);
|
||
const j3 = joinLobby(j2.lobby, 'Carol');
|
||
assert.ok(j3.ok);
|
||
if (!j3.ok) return;
|
||
assert.equal(j3.session.player, 2);
|
||
assert.deepEqual(j3.lobby.joinOrder, [s1.token, j2.session.token, j3.session.token]);
|
||
});
|
||
|
||
it('refuses a join once every chair is taken', () => {
|
||
let lobby = createLobby(competitive, 'Alice', 'RAIL-0001', 4).lobby;
|
||
for (const name of ['Bob', 'Carol', 'Dave']) {
|
||
const r = joinLobby(lobby, name);
|
||
assert.ok(r.ok);
|
||
if (r.ok) lobby = r.lobby;
|
||
}
|
||
const fifth = joinLobby(lobby, 'Eve');
|
||
assert.deepEqual(fifth, { ok: false, code: 'LOBBY_FULL' });
|
||
});
|
||
|
||
it('refuses a 2nd join to a one-chair table', () => {
|
||
const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0002', 1);
|
||
const second = joinLobby(lobby, 'Bob');
|
||
assert.deepEqual(second, { ok: false, code: 'LOBBY_FULL' });
|
||
});
|
||
|
||
it('rejoins into a seat an earlier player vacated, not past the end', () => {
|
||
// Joining always fills the FIRST empty seat, so a bot-seat cleared back to empty (setBotSeat)
|
||
// is exactly as joinable as one nobody ever filled.
|
||
let lobby = createLobby(competitive, 'Alice', 'RAIL-0003', 3).lobby;
|
||
lobby = setBotSeat(lobby, 1, true);
|
||
lobby = setBotSeat(lobby, 1, false);
|
||
const r = joinLobby(lobby, 'Bob');
|
||
assert.ok(r.ok);
|
||
if (!r.ok) return;
|
||
assert.equal(r.session.player, 1, 'should take the reopened chair 1');
|
||
assert.equal(r.lobby.seats.length, 3, 'joining must never resize the table');
|
||
assert.equal(r.lobby.seats[2], null);
|
||
});
|
||
|
||
it('never grows the table, whoever asks', () => {
|
||
// The old model appended a seat for anyone who turned up, which is how a lobby could end up
|
||
// holding more chairs than the host ever asked for.
|
||
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0013', 2);
|
||
const bob = joinLobby(lobby, 'Bob');
|
||
assert.ok(bob.ok);
|
||
if (!bob.ok) return;
|
||
assert.equal(bob.lobby.seats.length, 2);
|
||
assert.deepEqual(joinLobby(bob.lobby, 'Carol'), { ok: false, code: 'LOBBY_FULL' });
|
||
});
|
||
});
|
||
|
||
describe('bot seats', () => {
|
||
it('fills only an empty seat, and clears only a bot seat', () => {
|
||
const { lobby: l0 } = createLobby(competitive, 'Alice', 'RAIL-0004', 2);
|
||
const withBot = setBotSeat(l0, 1, true);
|
||
assert.deepEqual(withBot.seats[1], { kind: 'bot' });
|
||
|
||
// Filling an already-bot seat again is a no-op, not a crash.
|
||
const stillBot = setBotSeat(withBot, 1, true);
|
||
assert.deepEqual(stillBot.seats[1], { kind: 'bot' });
|
||
|
||
// Clearing seat 0 (a human) does nothing — a host is removed by leaving, not overwritten.
|
||
const untouched = setBotSeat(withBot, 0, false);
|
||
assert.deepEqual(untouched.seats[0], l0.seats[0]);
|
||
|
||
const cleared = setBotSeat(withBot, 1, false);
|
||
assert.equal(cleared.seats[1], null);
|
||
});
|
||
|
||
it('refuses a chair that is not at the table, instead of padding one in', () => {
|
||
// Padding is what used to put a hole in the seats array: dropping a bot into chair 3 of a
|
||
// 2-chair table grew it to 4 with a null at 2, and Start then refused for reasons the host
|
||
// had no way to see.
|
||
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0014', 2);
|
||
assert.equal(setBotSeat(lobby, 3, true), lobby);
|
||
assert.equal(setBotSeat(lobby, 2, true), lobby);
|
||
assert.equal(lobby.seats.length, 2);
|
||
});
|
||
});
|
||
|
||
describe('host transfer', () => {
|
||
it('passes to the earliest-joined remaining human seat when the host departs', () => {
|
||
let lobby = createLobby(competitive, 'Alice', 'RAIL-0005', 2).lobby;
|
||
const hostToken = lobby.hostToken;
|
||
const j2 = joinLobby(lobby, 'Bob');
|
||
assert.ok(j2.ok);
|
||
if (!j2.ok) return;
|
||
lobby = j2.lobby;
|
||
// Alice's own seat is cleared (simulating her leaving the table entirely, not just dropping
|
||
// her connection) so the transfer target is unambiguous in this test.
|
||
lobby = { ...lobby, seats: [null, lobby.seats[1]!] };
|
||
const after = reassignHost(lobby, hostToken);
|
||
assert.equal(after.hostToken, j2.session.token);
|
||
});
|
||
|
||
it('does nothing when the departing token is not the host', () => {
|
||
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0006', 2);
|
||
const after = reassignHost(lobby, 'not-a-real-token');
|
||
assert.equal(after, lobby);
|
||
});
|
||
|
||
it('leaves hostToken alone when no other human seat exists', () => {
|
||
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0007', 2);
|
||
const after = reassignHost(lobby, session.token);
|
||
assert.equal(after.hostToken, session.token);
|
||
});
|
||
});
|
||
|
||
describe('starting', () => {
|
||
it('refuses a non-host caller', () => {
|
||
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0008', 2);
|
||
joinLobby(lobby, 'Bob');
|
||
assert.deepEqual(startLobby(lobby, 'someone-elses-token'), { ok: false, code: 'NOT_HOST' });
|
||
});
|
||
|
||
it('refuses to start while a chair is still empty', () => {
|
||
const lobby = createLobby(competitive, 'Alice', 'RAIL-0009', 3).lobby;
|
||
const j2 = joinLobby(lobby, 'Bob');
|
||
assert.ok(j2.ok);
|
||
if (!j2.ok) return;
|
||
assert.deepEqual(startLobby(j2.lobby, j2.lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
|
||
});
|
||
|
||
it('refuses a solo human at a table sized for more', () => {
|
||
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0010', 2);
|
||
assert.deepEqual(startLobby(lobby, lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' });
|
||
});
|
||
|
||
it('starts a full 2-player lobby, naming humans by their display name and numbering the bot', () => {
|
||
let lobby = createLobby(competitive, 'Alice', 'RAIL-0011', 2).lobby;
|
||
lobby = setBotSeat(lobby, 1, true);
|
||
const r = startLobby(lobby, lobby.hostToken);
|
||
assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bot 1'], botSeats: [1] });
|
||
});
|
||
|
||
it('numbers bots so two of them at one table can be told apart', () => {
|
||
// They are two different railroads on the Division map, and a map that labels both "Bot"
|
||
// cannot answer "which one is that".
|
||
let lobby = createLobby(competitive, 'Alice', 'RAIL-0016', 3).lobby;
|
||
lobby = setBotSeat(setBotSeat(lobby, 1, true), 2, true);
|
||
const r = startLobby(lobby, lobby.hostToken);
|
||
assert.ok(r.ok);
|
||
if (!r.ok) return;
|
||
assert.deepEqual(r.playerNames, ['Alice', 'Bot 1', 'Bot 2']);
|
||
assert.deepEqual(r.botSeats, [1, 2]);
|
||
});
|
||
|
||
it('starts a solitaire lobby of exactly 1', () => {
|
||
const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0012', 1);
|
||
const r = startLobby(lobby, lobby.hostToken);
|
||
assert.deepEqual(r, { ok: true, playerNames: ['Alice'], botSeats: [] });
|
||
});
|
||
|
||
it('seat index is player index, with no compaction to shift it', () => {
|
||
// The seats array is never resized or squeezed, so the chair a player joined into is the
|
||
// player index the game gives them — which is what every PlayerSession already recorded at
|
||
// join time, and what /api/stream and /api/intent route by.
|
||
let lobby = createLobby(competitive, 'Alice', 'RAIL-0015', 4).lobby;
|
||
const bob = joinLobby(lobby, 'Bob');
|
||
assert.ok(bob.ok);
|
||
if (!bob.ok) return;
|
||
lobby = setBotSeat(setBotSeat(bob.lobby, 2, true), 3, true);
|
||
const r = startLobby(lobby, lobby.hostToken);
|
||
assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bob', 'Bot 1', 'Bot 2'], botSeats: [2, 3] });
|
||
assert.equal(bob.session.player, 1, "Bob's stored player index still names his chair");
|
||
});
|
||
});
|
||
|
||
describe('playerCountAllowed', () => {
|
||
it('solitaire is exactly 1', () => {
|
||
assert.equal(playerCountAllowed('solitaire', 1), true);
|
||
assert.equal(playerCountAllowed('solitaire', 0), false);
|
||
assert.equal(playerCountAllowed('solitaire', 2), false);
|
||
});
|
||
|
||
it('competitive and coop are 2-4', () => {
|
||
for (const mode of ['competitive', 'coop'] as const) {
|
||
assert.equal(playerCountAllowed(mode, 1), false);
|
||
assert.equal(playerCountAllowed(mode, 2), true);
|
||
assert.equal(playerCountAllowed(mode, 4), true);
|
||
assert.equal(playerCountAllowed(mode, 5), false);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('game codes', () => {
|
||
it('skips codes the caller marks taken', () => {
|
||
let calls = 0;
|
||
const code = freshGameCode(() => {
|
||
calls++;
|
||
return calls < 3; // taken twice, free on the third
|
||
});
|
||
assert.equal(typeof code, 'string');
|
||
assert.equal(calls, 3);
|
||
});
|
||
|
||
it('is speakable — a word, a dash, four digits', () => {
|
||
const code = freshGameCode(() => false);
|
||
assert.match(code, /^[A-Z]+-\d{4}$/);
|
||
});
|
||
});
|
||
|
||
describe('a name nobody else at the table is using', () => {
|
||
/**
|
||
* The display name is not decoration: it labels the district on the Division map, it is what the
|
||
* turn chart means by "waiting on Jesse", and `record()` puts it in front of every line that
|
||
* player causes. Two identical names make all three ambiguous, and the names lock at
|
||
* `Lobby.Start` — so the refusal has to happen at the door.
|
||
*/
|
||
it('refuses a second player using a name already at the table, whatever the case or spacing', () => {
|
||
const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0001', 4);
|
||
for (const attempt of ['Alice', 'alice', ' ALICE ']) {
|
||
const result = joinLobby(lobby, attempt);
|
||
assert.equal(result.ok, false, `"${attempt}" was allowed alongside Alice`);
|
||
if (!result.ok) assert.equal(result.code, 'NAME_TAKEN');
|
||
}
|
||
});
|
||
|
||
it('frees the name again when that player leaves', () => {
|
||
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0001', 4);
|
||
const joined = joinLobby(lobby, 'Bob');
|
||
assert.ok(joined.ok);
|
||
if (!joined.ok) return;
|
||
const after = leaveLobby(joined.lobby, joined.session.token);
|
||
const again = joinLobby(after.lobby, 'Bob');
|
||
assert.equal(again.ok, true, 'a departed player’s name was still held against the table');
|
||
assert.equal(after.empty, false, 'the host is still seated');
|
||
assert.equal(after.lobby.hostToken, session.token, 'a non-host leaving moved the host chair');
|
||
});
|
||
});
|
||
|
||
describe('leaving a lobby', () => {
|
||
/**
|
||
* There was no way out at all before 2026-08-23. Start needs every chair filled and `setBotSeat`
|
||
* refuses to touch an occupied human seat, so a mis-join or a player who wandered off wedged the
|
||
* whole table.
|
||
*/
|
||
const table = () => {
|
||
const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0001', 3);
|
||
const bob = joinLobby(lobby, 'Bob');
|
||
assert.ok(bob.ok);
|
||
if (!bob.ok) throw new Error('Bob could not sit down');
|
||
const carol = joinLobby(bob.lobby, 'Carol');
|
||
assert.ok(carol.ok);
|
||
if (!carol.ok) throw new Error('Carol could not sit down');
|
||
return { lobby: carol.lobby, alice: session, bob: bob.session, carol: carol.session };
|
||
};
|
||
|
||
it('empties the chair and forgets the token, leaving the rest of the table alone', () => {
|
||
const { lobby, bob } = table();
|
||
const after = leaveLobby(lobby, bob.token);
|
||
assert.equal(after.lobby.seats[1], null, 'the seat was not freed');
|
||
assert.equal(after.lobby.seats.length, 3, 'the table changed size');
|
||
assert.ok(!after.lobby.joinOrder.includes(bob.token), 'the departed token is still in the join order');
|
||
assert.equal((after.lobby.seats[0] as { displayName: string }).displayName, 'Alice');
|
||
assert.equal((after.lobby.seats[2] as { displayName: string }).displayName, 'Carol');
|
||
});
|
||
|
||
it('names a seat to clear somebody else — what the host’s "remove" does', () => {
|
||
const { lobby, alice, carol } = table();
|
||
const after = leaveLobby(lobby, alice.token, 2);
|
||
assert.equal(after.lobby.seats[2], null, 'the named seat was not cleared');
|
||
assert.ok(!after.lobby.joinOrder.includes(carol.token));
|
||
// Seat index IS player index (`lobby.ts`), so nothing may shuffle up to fill the hole.
|
||
assert.equal((after.lobby.seats[0] as { displayName: string }).displayName, 'Alice');
|
||
assert.equal((after.lobby.seats[1] as { displayName: string }).displayName, 'Bob');
|
||
});
|
||
|
||
it('passes host rights on when the host is the one who leaves', () => {
|
||
const { lobby, alice, bob } = table();
|
||
const after = leaveLobby(lobby, alice.token);
|
||
assert.equal(after.lobby.hostToken, bob.token, 'the earliest-joined remaining player did not become host');
|
||
assert.equal(after.empty, false);
|
||
});
|
||
|
||
it('reports the table empty when the last human goes, so the caller can drop it', () => {
|
||
const { lobby, alice, bob, carol } = table();
|
||
const one = leaveLobby(lobby, bob.token);
|
||
const two = leaveLobby(one.lobby, carol.token);
|
||
assert.equal(two.empty, false, 'Alice is still sitting there');
|
||
const three = leaveLobby(two.lobby, alice.token);
|
||
assert.equal(three.empty, true, 'a lobby with nobody human in it did not report itself empty');
|
||
});
|
||
|
||
it('leaves a bot seat to the bot controls, and ignores a token holding no seat', () => {
|
||
const { lobby, alice } = table();
|
||
const withBot = setBotSeat(leaveLobby(lobby, alice.token, 1).lobby, 1, true);
|
||
// `leaveLobby` is about people. A bot is removed with the button that put it there.
|
||
const after = leaveLobby(withBot, alice.token, 1);
|
||
assert.deepEqual(after.lobby.seats[1], { kind: 'bot' }, 'leaving cleared a bot seat');
|
||
const stranger = leaveLobby(withBot, 'not-a-token');
|
||
assert.equal(stranger.lobby, withBot, 'an unknown token changed the lobby');
|
||
});
|
||
});
|