Files
Jesse.MarkowitzandClaude Fable 5.1 04ca74c365 v0.8.5 — housekeeping from the audit, and the playtest line retired
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
2026-09-29 17:02:33 -04:00

351 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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');
});
});