asks what game you want Three queued items. The last matters most. A RELEASE NO LONGER DESTROYS EVERY GAME IN PROGRESS. Four consecutive releases killed every game on the box, one of them a release that changed only how the board is drawn. The reasoning behind the refusal was always right — a move legal under old rules may not be legal under new ones, and half-replaying a save is worse than refusing it. The TEST was wrong: it compared engineVersion for exact equality, and that stamp is the package version, which moves for a CSS fix. Whether a save still replays has an exact answer, so it is now asked directly. loadGame reads the file and judges nothing; tryResumeSession replays the intents and reports the first one the engine refuses. A save stamped with a version this server has never run resumes fine provided its moves replay — verified against a file hand-stamped 0.4.9-ancient. One that genuinely does not replay is still refused, but the log names the move rather than two version strings: "move 3 of 8 (localOps.choose) is rejected by the current rules with OPTION_ALREADY_CHOSEN". fromMultiplayerSave had to stop lying first. It has always stopped at the first unacceptable intent and done so in silence, which was survivable only because the version gate meant a doomed replay was never attempted. Now that the replay IS the check, it returns where it stopped and why. Deliberately not done: resuming a partly-replayable game at its last good move. That silently rewinds a game to a position nobody played to while every browser holding a later Frame carries on unaware. Refusing leaves the file intact, so putting the previous version back still recovers it. EMPLOYEE ROTATION IS IMPLEMENTED, SISTER TRAINS IS DELETED. Two of the four optional-rule flags were read by nothing at all. Employee Rotation is four lines in advance.ts, because the seat/player split (D9) exists for precisely this rule: seating is the only thing that moves, so Revenue, hands, the Superintendent and whose turn it is travel with the player, and the Office, district, grid and any trains standing in it stay with the chair. Inheriting the district you move into is the point of the rule, not a side effect. "Left" is seat + 1, matching playerLeftOf. Sister Trains is deleted rather than built: Q9 records that the Second Section card supersedes it, and that card exists, so the flag was a toggle for a rule the game no longer has. THE LOBBY ASKS WHAT GAME YOU WANT TO PLAY. Creating a game asked for a name, a mode and a table size; every other dial was hardcoded. A Game settings block now carries the same set the solitaire dialog does — seed, starting hand, the three revenue rates, Days, the combined-Revenue floor, both collision caps, the opponent-card toggle — plus the three surviving optional rules. Mode and table size set the defaults and everything stays editable. The seed is honoured, so a game can be reproduced or compared. Verified: 682 tests pass (679 + 3). The rotation tests were mutation-checked both ways — disabling the rotation and turning the table the wrong way each fail the suite. Live: a save stamped 0.4.9-ancient resumed, an injected illegal move was refused by name, and a create with every dial set to a non-default value came back out of game.json with all of them intact, including seed 777. Two of my own assertions were wrong on the way and the tests caught them: the Fedora legitimately passes at Stage 12 (§5) so it cannot be compared against its own earlier value, and dispatchUsedToday is cleared at every Day boundary so it cannot mark a district.
253 lines
9.9 KiB
TypeScript
253 lines
9.9 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,
|
|
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}$/);
|
|
});
|
|
});
|