/** * 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, playerCountAllowed, reassignHost, setBotSeat, startLobby, } from '../../src/server/lobby.ts'; const competitive: GameConfig = { mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, 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((c) => { 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}$/); }); });