Both halves came out of playing the StartOS build. The wrapper's health
check and admin actions consume this; they land separately.
The host picks the table size (2-4) when creating a game, and the seats
array is built at that length once. Before, it GREW as people joined, so
the four rows on screen were partly fiction — a 2-player game just started
with a 2-long array, while a host who dropped a bot into a later chair
padded it with a null and silently disabled Start behind a one-line note.
A gap can no longer be written down rather than merely being refused.
That also avoided a trap. Compacting seats at Lobby.Start — the obvious
way to support a "closed" chair — would have shifted the player index that
every PlayerSession stamps at join time and that /api/stream and
/api/intent both route by, handing a player somebody else's railroad with
no error anywhere.
And it fixed a live balance bug: minCombinedRevenue is derived from the
player count, but the config was fixed at CREATE while the count wasn't
known until START, so the lobby guessed 4. Every 2-player game ran against
a floor of 60 instead of 30 — and missing the floor means everyone loses,
so a 2-player competitive game was set up to fail for a UI artifact rather
than a rule.
/api/health gained games:{active,lobby}, read from a new cheap summary()
on GameSession rather than exportSave(), which would copy every intent of
every game to answer a question about none of them. Three admin routes are
new behind an ADMIN_SECRET env var in an x-admin-secret header: GET
/api/games, GET /api/games/<id>/save, DELETE /api/games/<id>. Until now a
started game could not be ended by anyone — no route, no player action, no
resignation — so an abandoned game stayed active in the index and was
faithfully resumed on every boot, forever.
Three deliberate choices there: the admin secret is NOT the join secret,
which every player holds and which would therefore let anyone at the table
destroy anyone else's game; unset means the routes 404 exactly as any
unknown path does, with or without a header, so a server never given an
administrator doesn't advertise that it has one; and a delete returns the
deleted game's save, since the intents are the game (D5) — nothing is
destroyed without being handed to whoever destroyed it.
SavedGame gained an optional lastMoveAt (falling back to createdAt) so
"has this stalled?" survives a restart. Kept out of history for the same
reason the turn timings are: a replay must reproduce a game from decisions
alone, and wall-clock is not a decision.
index.ts logs "Resuming N saved games..." before the loop rather than one
line per game after it. Measured a full 4-player game at 100ms to replay,
and only unfinished games are replayed, so listening before loading would
have bought nothing for the cost of a "still loading" state everywhere.
Verified: 667 tests pass (662 + 5), and the new session tests were checked
against two mutations (lastMoveAt never advancing; resume dropping it) to
confirm they fail without the code. Live against a running server: health
counts tracking through the lobby->game transition, admin auth rejecting a
missing and a wrong secret, list/export/delete, the deleted game's files
and index entry actually gone from disk, a second delete 404ing, the admin
routes invisible when ADMIN_SECRET is unset, and a 3-player table refusing
a 4th player and a size of 5 refused at the door.
Also carries the TODO items raised on 2026-08-21: the lobby offering no
game parameters (the floor bug within it now fixed, the form still
missing), and the four optionalRules — of which only reducedVisibility and
emergencyToolbox are read by anything, while sisterTrains and
employeeRotation are declared, defaulted, and consulted nowhere.
242 lines
9.4 KiB
TypeScript
242 lines
9.4 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,
|
|
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('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 bots "Bot" and humans by their display name', () => {
|
|
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'], botSeats: [1] });
|
|
});
|
|
|
|
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', 'Bot'], 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}$/);
|
|
});
|
|
});
|