/** * 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'); }); });