/** * 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, sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, }; const solitaire: GameConfig = { ...competitive, mode: 'solitaire' }; describe('creating and joining', () => { it('the creator is the host, takes seat 0, and is first in join order', () => { const { lobby, session } = createLobby(competitive, 'Alice', 'RAIL-0001'); assert.equal(session.player, 0); assert.equal(lobby.hostToken, session.token); assert.equal(lobby.seats.length, 1); assert.deepEqual(lobby.seats[0], { kind: 'human', token: session.token, displayName: 'Alice' }); assert.deepEqual(lobby.joinOrder, [session.token]); }); it('fills the next empty seat, in order', () => { const { lobby: l1, session: s1 } = createLobby(competitive, 'Alice', 'RAIL-0001'); 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 5th join to a competitive lobby (cap 4)', () => { let lobby = createLobby(competitive, 'Alice', 'RAIL-0001').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 solitaire lobby (cap 1)', () => { const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0002'); 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').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 seat 1, not append at seat 1 anyway by coincidence — check seat 2 stays empty'); assert.equal(r.lobby.seats.length, 2); }); }); describe('bot seats', () => { it('fills only an empty seat, and clears only a bot seat', () => { const { lobby: l0 } = createLobby(competitive, 'Alice', 'RAIL-0004'); 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); }); }); describe('host transfer', () => { it('passes to the earliest-joined remaining human seat when the host departs', () => { let lobby = createLobby(competitive, 'Alice', 'RAIL-0005').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'); 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'); 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'); joinLobby(lobby, 'Bob'); assert.deepEqual(startLobby(lobby, 'someone-elses-token'), { ok: false, code: 'NOT_HOST' }); }); it('refuses to start with a gap in the seats', () => { let lobby = createLobby(competitive, 'Alice', 'RAIL-0009').lobby; const j2 = joinLobby(lobby, 'Bob'); assert.ok(j2.ok); if (!j2.ok) return; const j3 = joinLobby(j2.lobby, 'Carol'); assert.ok(j3.ok); if (!j3.ok) return; lobby = { ...j3.lobby, seats: [j3.lobby.seats[0]!, null, j3.lobby.seats[2]!] }; assert.deepEqual(startLobby(lobby, lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' }); }); it('refuses a solo human in a competitive lobby (needs 2-4)', () => { const { lobby } = createLobby(competitive, 'Alice', 'RAIL-0010'); assert.deepEqual(startLobby(lobby, lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' }); }); it('starts a full 2-player lobby, naming bots "Bot" and humans by their display name', () => { let lobby = createLobby(competitive, 'Alice', 'RAIL-0011').lobby; lobby = setBotSeat(lobby, 1, true); const r = startLobby(lobby, lobby.hostToken); assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bot'], botSeats: [1] }); }); it('starts a solitaire lobby of exactly 1', () => { const { lobby } = createLobby(solitaire, 'Alice', 'RAIL-0012'); const r = startLobby(lobby, lobby.hostToken); assert.deepEqual(r, { ok: true, playerNames: ['Alice'], botSeats: [] }); }); }); 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}$/); }); });