v0.7.0 — four game types, a lobby you can read and leave, and a multiplayer game that makes a sound
The multiplayer set-up, the lobby, the start of a game, and four signals a remote client had never been sent. Reasoning, the preset table and what was verified how: CHANGELOG.md. - Co-op, Competitive, Cutthroat, Solitaire and Custom, on both screens, from one shared block — they had drifted, and each was missing a question the other asked. - A player reads the whole rule set before taking a seat, may leave a lobby or a running game, and keeps a seat across a reload. The host may clear a chair. The browser remembers every game it is in, not just the last one. - The start of a game is drawn: a handoff beat, an announcement, the code and type in the header. - Sound, the timetable flash, announcements and the just-drawn badge now reach a remote client; justDrawn goes to the seat that drew it and nobody else. - Played on StartOS, which found the rest: an Extra belongs to the player who played it, the board never named the Superintendent, bot seats were reported as absent players, and rule section numbers are out of every string a player reads. Also carries the previous session's Heavy Grade documentation work — asked again, answer unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016JczK5i33ZNSf2PtzZqdhS
This commit is contained in:
co-authored by
Claude Opus 5
parent
42adfda390
commit
06db36e5b5
@@ -1036,7 +1036,8 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => {
|
||||
*/
|
||||
const pending = (trainNumber: number, tier?: 'depot' | 'station'): GameState => {
|
||||
const s = game(11);
|
||||
s.pendingExtras = [trainNumber];
|
||||
// Queued by seat 0, who is therefore the one §7 lets place it.
|
||||
s.pendingExtras = [{ trainNumber, player: 0 }];
|
||||
s.timetable = s.timetable.map(() => null);
|
||||
if (tier) s.officeAreas.get(0)!.tier = tier;
|
||||
s.clock.phase = 'newTrain';
|
||||
|
||||
+101
-1
@@ -11,9 +11,10 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaAtSeat, areaOf } from '../src/engine/apply.ts';
|
||||
import { applyIntent, areaAtSeat, areaOf, check } from '../src/engine/apply.ts';
|
||||
import { STAGES_PER_DAY, STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
|
||||
import { coordKey, playerAtSeat, playerLeftOf, seatOf, subdivisions } from '../src/engine/state.ts';
|
||||
import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||
@@ -675,6 +676,105 @@ describe('the New Train phase car-placement round rotates (§7, Gap 9)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('an Extra belongs to the player who played it (§7)', () => {
|
||||
/**
|
||||
* REPORTED BY JESSE 2026-08-23, from a two-player game on StartOS: one seat played a train card
|
||||
* and the OTHER was asked to build the train. For a Timetabled train that is correct — the round
|
||||
* above starts at the Superintendent — but §7 states the Extra rule a paragraph later and it is
|
||||
* the opposite one: "the player who played the card may place the Crew Tray at either Division
|
||||
* Point ... and may load the consist as he chooses."
|
||||
*
|
||||
* The engine could not honour it: `pendingExtras` was a bare `number[]`, so nothing recorded whose
|
||||
* Extra it was and the phase asked whoever the acting order happened to be on. Invisible in
|
||||
* solitaire, where that is always the same person.
|
||||
*/
|
||||
const withPendingExtra = (players: number, owner: PlayerIndex) => {
|
||||
const s = game(players);
|
||||
s.clock.phase = 'newTrain';
|
||||
s.timetable = s.timetable.map(() => null);
|
||||
// X22 "Pee-Dee" — a caboose-only Extra, so the consist is short and the round would be visible.
|
||||
s.pendingExtras = [{ trainNumber: 22, player: owner }];
|
||||
return s;
|
||||
};
|
||||
|
||||
it('asks the player who played it where it starts — not whoever the round is on', () => {
|
||||
// The owner is deliberately NOT the Superintendent, which is the case that used to go wrong.
|
||||
const s = withPendingExtra(2, 1 as PlayerIndex);
|
||||
const notSuper = playerLeftOf(s, s.clock.superintendent, 1);
|
||||
s.pendingExtras = [{ trainNumber: 22, player: notSuper }];
|
||||
|
||||
const r = advance(s);
|
||||
assert.equal(r.needsInput, true, 'the phase did not stop to place the Extra');
|
||||
assert.equal(s.clock.currentActor, notSuper, 'the wrong player was asked where the Extra starts');
|
||||
});
|
||||
|
||||
it('refuses another seat placing it, and offers it to nobody else', () => {
|
||||
const s = withPendingExtra(2, 0 as PlayerIndex);
|
||||
const intent = {
|
||||
type: 'newTrain.startExtra' as const,
|
||||
trainNumber: 22,
|
||||
start: { kind: 'divisionPoint' as const, side: 'west' as const },
|
||||
};
|
||||
const theirs = applyIntent(s, 0 as PlayerIndex, intent);
|
||||
assert.ok(theirs.ok, `the owner could not place their own Extra — ${theirs.ok ? '' : theirs.code}`);
|
||||
|
||||
/**
|
||||
* TWO EXTRAS, TWO OWNERS — the case where the guard is actually reachable. The phase stops on
|
||||
* the first pending Extra's owner, so seat 0 is legitimately the current actor; nothing but this
|
||||
* rule stops them placing seat 1's train while they are there.
|
||||
*/
|
||||
const both = withPendingExtra(2, 0 as PlayerIndex);
|
||||
both.pendingExtras = [
|
||||
{ trainNumber: 22, player: 0 as PlayerIndex },
|
||||
{ trainNumber: 24, player: 1 as PlayerIndex },
|
||||
];
|
||||
advance(both);
|
||||
assert.equal(both.clock.currentActor, 0, 'the first pending Extra should have stopped on its owner');
|
||||
assert.equal(check(both, 0 as PlayerIndex, { ...intent, trainNumber: 24 }), 'NOT_YOUR_EXTRA');
|
||||
const notTheirs = applyIntent(both, 0 as PlayerIndex, { ...intent, trainNumber: 24 });
|
||||
assert.equal(notTheirs.ok, false, 'a seat placed somebody else’s Extra while it was their turn');
|
||||
// And it is not even offered: a menu that lists an action `check` will refuse is a menu lying.
|
||||
assert.equal(
|
||||
legalActions(both, 0 as PlayerIndex).some(
|
||||
(i) => i.type === 'newTrain.startExtra' && i.trainNumber === 24,
|
||||
),
|
||||
false,
|
||||
'somebody else’s Extra was offered a start point',
|
||||
);
|
||||
});
|
||||
|
||||
it('lets its player load the whole consist, rather than passing the round', () => {
|
||||
// §7: "may load the consist as he chooses" — no going round the table for an Extra.
|
||||
const s = withPendingExtra(2, 0 as PlayerIndex);
|
||||
const owner = 0 as PlayerIndex;
|
||||
const placed = applyIntent(s, owner, {
|
||||
type: 'newTrain.startExtra',
|
||||
trainNumber: 22,
|
||||
start: { kind: 'divisionPoint', side: 'west' },
|
||||
});
|
||||
assert.ok(placed.ok, 'the Extra could not be placed');
|
||||
s.yards.divisionYard.push({ type: 'caboose', loaded: false }, { type: 'caboose', loaded: false });
|
||||
|
||||
const asked: PlayerIndex[] = [];
|
||||
for (let guard = 0; guard < 4; guard++) {
|
||||
const r = advance(s);
|
||||
if (!r.needsInput) break;
|
||||
const actor = s.clock.currentActor!;
|
||||
asked.push(actor);
|
||||
const tray = [...s.trays.values()].find((t) => t.trainIsExtra);
|
||||
assert.ok(tray, 'the Extra has no tray');
|
||||
const result = applyIntent(s, actor, { type: 'newTrain.passCar', trayId: tray.id });
|
||||
if (!result.ok) break;
|
||||
}
|
||||
assert.ok(asked.length > 0, 'nobody was asked to load the Extra');
|
||||
assert.deepEqual(
|
||||
[...new Set(asked)],
|
||||
[owner],
|
||||
`the Extra's consist went round the table (asked ${asked.join(', ')}) instead of staying with its player`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('actionMenu is seat-safe (Phase 2 prep)', () => {
|
||||
/**
|
||||
* REGRESSION. `actionMenu(game, seat)` used `seat` only for the `hand` field — `options`/`direct`/
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* The four game types (`src/web/presets.ts`) — Jesse's design, 2026-08-23.
|
||||
*
|
||||
* These numbers are a DESIGN, not an implementation detail: they say what Co-op asks of a table and
|
||||
* what Cutthroat refuses to. Pinned here so that changing one is a decision somebody makes on
|
||||
* purpose rather than something a refactor can do quietly.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
PRESETS,
|
||||
closestPreset,
|
||||
configFromPreset,
|
||||
configFromSettings,
|
||||
differencesFrom,
|
||||
gameTypeLabel,
|
||||
preset,
|
||||
presetOf,
|
||||
presetSettings,
|
||||
settingsOf,
|
||||
} from '../src/web/presets.ts';
|
||||
import type { PresetName } from '../src/web/presets.ts';
|
||||
|
||||
const NAMES: PresetName[] = ['solitaire', 'coop', 'competitive', 'cutthroat'];
|
||||
|
||||
describe('what each game type is', () => {
|
||||
it('deals six cards in every type — the hand limit is three, so the first turn is a discard', () => {
|
||||
for (const name of NAMES) {
|
||||
assert.equal(presetSettings(name, 4, 5).startingHand, 'sixRandom', `${name} does not deal six`);
|
||||
}
|
||||
});
|
||||
|
||||
it('scores Cutthroat as Competitive, and Co-op as itself', () => {
|
||||
assert.equal(preset('cutthroat').scoring, 'competitive');
|
||||
assert.equal(preset('competitive').scoring, 'competitive');
|
||||
assert.equal(preset('coop').scoring, 'coop');
|
||||
assert.equal(preset('solitaire').scoring, 'solitaire');
|
||||
});
|
||||
|
||||
it('lets the opponent-directed cards into Competitive and Cutthroat only', () => {
|
||||
// Co-op has no opponent to point them at; solitaire has nobody at all. Inert either way until
|
||||
// the cards are built (`setup.ts`), which is why no screen offers this as a control any more.
|
||||
assert.deepEqual(
|
||||
PRESETS.filter((p) => p.pvpCards).map((p) => p.name),
|
||||
['competitive', 'cutthroat'],
|
||||
);
|
||||
});
|
||||
|
||||
it('pays for a transit in Co-op alone — the one economy that pays everybody at once', () => {
|
||||
assert.equal(presetSettings('coop', 4, 5).trainPerTransit, 1);
|
||||
for (const name of ['solitaire', 'competitive', 'cutthroat'] as PresetName[]) {
|
||||
assert.equal(presetSettings(name, 4, 5).trainPerTransit, 0, `${name} pays for transits`);
|
||||
}
|
||||
});
|
||||
|
||||
it('lets an Extra be planted in another player’s district in Cutthroat only', () => {
|
||||
assert.equal(presetSettings('cutthroat', 4, 5).extraStart, 'anyOffice');
|
||||
assert.equal(presetSettings('coop', 4, 5).extraStart, 'ownOffice');
|
||||
assert.equal(presetSettings('competitive', 4, 5).extraStart, 'ownOffice');
|
||||
// At one player the two rules are the same rule.
|
||||
assert.equal(presetSettings('solitaire', 1, 5).extraStart, 'anyOffice');
|
||||
});
|
||||
|
||||
it('leaves every optional rule off, in every type', () => {
|
||||
for (const name of NAMES) {
|
||||
const s = presetSettings(name, 4, 5);
|
||||
assert.deepEqual(
|
||||
[s.reducedVisibility, s.employeeRotation, s.emergencyToolbox],
|
||||
[false, false, false],
|
||||
`${name} switches an optional rule on`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Revenue floor is a formula, not a number', () => {
|
||||
/**
|
||||
* Jesse gave these at five Days — Co-op 15 × players, Competitive 10 × players — and they scale,
|
||||
* because a ten-Day game with a five-Day target is not a target. The floor follows the table size
|
||||
* AND the length, which is exactly why both sit above the type radios as parameters rather than
|
||||
* below them as rules.
|
||||
*/
|
||||
it('asks 15 per player in Co-op at five Days, and 10 in Competitive', () => {
|
||||
for (const players of [2, 3, 4]) {
|
||||
assert.equal(presetSettings('coop', players, 5).minCombinedRevenue, 15 * players);
|
||||
assert.equal(presetSettings('competitive', players, 5).minCombinedRevenue, 10 * players);
|
||||
}
|
||||
});
|
||||
|
||||
it('scales both ways with the Day count', () => {
|
||||
assert.equal(presetSettings('coop', 3, 8).minCombinedRevenue, 3 * 3 * 8);
|
||||
assert.equal(presetSettings('coop', 3, 3).minCombinedRevenue, 3 * 3 * 3);
|
||||
assert.equal(presetSettings('competitive', 4, 10).minCombinedRevenue, 2 * 4 * 10);
|
||||
assert.equal(presetSettings('competitive', 2, 3).minCombinedRevenue, 2 * 2 * 3);
|
||||
});
|
||||
|
||||
it('asks nothing at all in Cutthroat, and leaves only the per-Day collision check standing', () => {
|
||||
const s = presetSettings('cutthroat', 4, 5);
|
||||
assert.equal(s.minCombinedRevenue, 0, 'Cutthroat has a Revenue floor');
|
||||
assert.equal(s.maxCollisionsTotal, 0, 'Cutthroat caps collisions across the game');
|
||||
assert.equal(s.maxCollisionsPerDay, 3, 'three collisions in one Day still ends a Cutthroat game');
|
||||
});
|
||||
});
|
||||
|
||||
describe('naming a game from its numbers', () => {
|
||||
it('reads every type back as itself, at every table size and length', () => {
|
||||
for (const name of NAMES) {
|
||||
const players = name === 'solitaire' ? 1 : 3;
|
||||
for (const days of [3, 5, 10]) {
|
||||
const config = configFromPreset(name, players, days);
|
||||
assert.equal(presetOf(config, players, days), name, `${name} at ${days} Days did not read back`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('tells Cutthroat and Competitive apart, though they are scored the same way', () => {
|
||||
const cut = configFromPreset('cutthroat', 4, 5);
|
||||
const comp = configFromPreset('competitive', 4, 5);
|
||||
assert.equal(cut.mode, comp.mode, 'these two are meant to share a scoring mode');
|
||||
assert.equal(presetOf(cut, 4, 5), 'cutthroat');
|
||||
assert.equal(presetOf(comp, 4, 5), 'competitive');
|
||||
});
|
||||
|
||||
it('calls a changed rule Custom, and says which rule', () => {
|
||||
const base = configFromPreset('coop', 4, 5);
|
||||
const settings = { ...settingsOf(base), emergencyToolbox: true };
|
||||
const custom = configFromSettings(settings, 'coop', 5, false);
|
||||
assert.equal(presetOf(custom, 4, 5), 'custom');
|
||||
assert.deepEqual(differencesFrom('coop', settings, 4, 5), ['emergencyToolbox']);
|
||||
});
|
||||
|
||||
it('does not call a Co-op game Competitive just because its dials line up', () => {
|
||||
// The scoring mode is part of the comparison: the same numbers under a different mode are a
|
||||
// different game, and the type is what a player reads to know which.
|
||||
const settings = presetSettings('competitive', 4, 5);
|
||||
const coopScored = configFromSettings(settings, 'coop', 5, false);
|
||||
assert.notEqual(presetOf(coopScored, 4, 5), 'competitive');
|
||||
assert.equal(presetOf(coopScored, 4, 5), 'custom');
|
||||
});
|
||||
|
||||
it('measures a Custom game against the nearest type it is scored as', () => {
|
||||
// A join preview is handed a finished config and nothing else — "Custom" alone would tell a
|
||||
// player nothing about what they are sitting down to.
|
||||
const settings = { ...presetSettings('cutthroat', 4, 5), freightPerLoad: 3 };
|
||||
const config = configFromSettings(settings, 'competitive', 5, true);
|
||||
const near = closestPreset(config, 4, 5);
|
||||
assert.equal(near.name, 'cutthroat', 'a tuned Cutthroat game was measured against something else');
|
||||
assert.deepEqual(near.differing, ['freightPerLoad']);
|
||||
});
|
||||
|
||||
it('says what a Custom game is scored as, since the dials cannot', () => {
|
||||
assert.match(gameTypeLabel('custom', 'coop'), /Co-op/);
|
||||
assert.match(gameTypeLabel('custom', 'competitive'), /Competitive/);
|
||||
assert.equal(gameTypeLabel('cutthroat', 'competitive'), 'Cutthroat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the config a form produces', () => {
|
||||
it('carries the type’s scoring mode and its stance on the opponent cards', () => {
|
||||
for (const name of NAMES) {
|
||||
const config = configFromPreset(name, name === 'solitaire' ? 1 : 4, 5);
|
||||
assert.equal(config.mode, preset(name).scoring);
|
||||
assert.equal(config.pvpCardsAllowed, preset(name).pvpCards);
|
||||
}
|
||||
});
|
||||
|
||||
it('spells out every house rule rather than leaving one to a default somewhere else', () => {
|
||||
const config = configFromPreset('competitive', 3, 5);
|
||||
assert.ok(config.houseRules?.startingHand, 'the opening hand was left unnamed');
|
||||
assert.ok(config.houseRules?.extraStart, 'the Extra rule was left unnamed');
|
||||
assert.deepEqual(config.houseRules?.revenue, {
|
||||
passengerPerCoach: 1,
|
||||
freightPerLoad: 1,
|
||||
trainPerTransit: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createLobby,
|
||||
freshGameCode,
|
||||
joinLobby,
|
||||
leaveLobby,
|
||||
playerCountAllowed,
|
||||
reassignHost,
|
||||
setBotSeat,
|
||||
@@ -250,3 +251,96 @@ describe('game codes', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -347,3 +347,68 @@ describe('a save survives a release that did not change the rules', () => {
|
||||
assert.equal(r.ok, false, 'a truncated replay was returned as a healthy session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the four transient signals (2026-08-23)', () => {
|
||||
/**
|
||||
* Multiplayer had none of these: `createRemoteSession` answered every one of them with an empty
|
||||
* value, so a game on a server had no sound, no timetable flash, no announcement when a completed
|
||||
* run paid the table, and no badge on the card you had just drawn. They ride on the push now — and
|
||||
* `justDrawn` is the one that has to be careful, because `game.justDrawn` is ONE field for the
|
||||
* whole game and does not say whose card it is.
|
||||
*/
|
||||
|
||||
/** Drives the game until the current actor draws a card, and returns that turn's pushes. */
|
||||
const drawSomething = (session: GameSession, players: number): { seat: PlayerIndex; pushes: Map<PlayerIndex, Push> } => {
|
||||
for (let seat = 0 as PlayerIndex; seat < players; seat++) {
|
||||
if (session.connect(seat).menu === null) continue;
|
||||
// Two steps: §6's three options are exclusive, so the turn is spent on drawing before a card
|
||||
// actually leaves the deck.
|
||||
const chose = session.intent(seat, 1, { type: 'localOps.choose', option: 'draw' });
|
||||
assert.equal(chose.accepted, true, 'the actor could not choose to draw');
|
||||
const result = session.intent(seat, 2, { type: 'draw.fromHomeOffice' });
|
||||
assert.equal(result.accepted, true, 'the actor could not draw from the Home Office deck');
|
||||
if (!result.accepted) throw new Error('unreachable');
|
||||
return { seat, pushes: result.pushes };
|
||||
}
|
||||
throw new Error('no seat was able to act');
|
||||
};
|
||||
|
||||
it('sends the drawn card to the seat that drew it, and to nobody else', () => {
|
||||
const session = createSession(42, config, ['Alice', 'Bob', 'Carol']);
|
||||
const { seat, pushes } = drawSomething(session, 3);
|
||||
const mine = pushes.get(seat);
|
||||
assert.ok(mine?.justDrawn, 'the drawing seat was not told which card it drew');
|
||||
for (const [other, push] of pushes) {
|
||||
if (other === seat) continue;
|
||||
assert.equal(
|
||||
push.justDrawn,
|
||||
undefined,
|
||||
`seat ${other} was told which card seat ${seat} drew — that is a hand leak`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the badge across a reconnect, still only for its owner', () => {
|
||||
const session = createSession(42, config, ['Alice', 'Bob', 'Carol']);
|
||||
const { seat, pushes } = drawSomething(session, 3);
|
||||
const drawn = pushes.get(seat)?.justDrawn;
|
||||
assert.equal(session.connect(seat).justDrawn, drawn, 'a refresh lost the card the player just drew');
|
||||
const other = ((seat + 1) % 3) as PlayerIndex;
|
||||
assert.equal(session.connect(other).justDrawn, undefined, 'a reconnecting seat was told about someone else’s draw');
|
||||
});
|
||||
|
||||
it('sends the shared signals to every seat, identically, and drains them', () => {
|
||||
// A collision anywhere on the Division, the Stage bell, a train running off the end and paying
|
||||
// everyone: these are the table's, not one player's.
|
||||
const session = createSession(42, config, ['Alice', 'Bob', 'Carol']);
|
||||
const { pushes } = drawSomething(session, 3);
|
||||
const cues = [...pushes.values()].map((p) => JSON.stringify(p.cues ?? []));
|
||||
assert.equal(new Set(cues).size, 1, 'seats were sent different sound cues for the same events');
|
||||
|
||||
// Drained: a signal marks a moment, so a later connect must not replay it.
|
||||
const later = session.connect(0 as PlayerIndex);
|
||||
assert.equal(later.cues, undefined, 'a reconnecting client was sent the sounds of what it missed');
|
||||
assert.equal(later.announcement, undefined, 'a reconnecting client was re-sent an old announcement');
|
||||
assert.equal(later.scheduled, undefined, 'a reconnecting client was re-sent an old timetable flash');
|
||||
});
|
||||
});
|
||||
|
||||
+559
-106
@@ -23,6 +23,8 @@ import { variantsFor } from '../src/engine/track.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import { ENHANCEMENT_RULES } from '../src/engine/content.ts';
|
||||
import { facilitiesHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||
import { fieldSelectors } from '../src/web/settings-form.ts';
|
||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
||||
@@ -1525,6 +1527,10 @@ describe('the static build', () => {
|
||||
},
|
||||
createElement: () => make(),
|
||||
addEventListener: () => {},
|
||||
// The shared rules block (`settings-form.ts`) addresses its radio groups by NAME through the
|
||||
// document, since they live in two different screens. Empty is the right answer here: these
|
||||
// suites drive the BOARD, not the New Game dialog.
|
||||
querySelectorAll: () => [],
|
||||
body: { appendChild: () => {} },
|
||||
// Capture injected stylesheets. The board is SVG styled entirely by class, so a page that
|
||||
// renders every card correctly and never loads BOARD_CSS draws them black on black — visibly
|
||||
@@ -1683,6 +1689,10 @@ describe('the static build', () => {
|
||||
},
|
||||
createElement: () => make(),
|
||||
addEventListener: () => {},
|
||||
// The shared rules block (`settings-form.ts`) addresses its radio groups by NAME through the
|
||||
// document, since they live in two different screens. Empty is the right answer here: these
|
||||
// suites drive the BOARD, not the New Game dialog.
|
||||
querySelectorAll: () => [],
|
||||
body: { appendChild: () => {} },
|
||||
head: { appendChild: (node: Record<string, unknown>) => void injected.push(node) },
|
||||
};
|
||||
@@ -1764,6 +1774,10 @@ describe('the static build', () => {
|
||||
},
|
||||
createElement: () => make(),
|
||||
addEventListener: () => {},
|
||||
// The shared rules block (`settings-form.ts`) addresses its radio groups by NAME through the
|
||||
// document, since they live in two different screens. Empty is the right answer here: these
|
||||
// suites drive the BOARD, not the New Game dialog.
|
||||
querySelectorAll: () => [],
|
||||
body: { appendChild: () => {} },
|
||||
head: { appendChild: () => {} },
|
||||
};
|
||||
@@ -2052,7 +2066,8 @@ describe('the static build', () => {
|
||||
// since the Extra has not chosen where it starts yet), that group was the only thing offered and
|
||||
// it vanished from the page — a legal decision with zero buttons.
|
||||
const game = newGame(430);
|
||||
game.state.pendingExtras.push(22); // Extra X22 "Pee-Dee" — one caboose, per-diem.
|
||||
// Solitaire: seat 0 played it, so seat 0 places it (§7).
|
||||
game.state.pendingExtras.push({ trainNumber: 22, player: 0 }); // Extra X22 "Pee-Dee" — one caboose, per-diem.
|
||||
game.state.clock.phase = 'newTrain';
|
||||
game.state.clock.currentActor = 0;
|
||||
assert.equal(currentActor(game), 0, 'a decision should be waiting');
|
||||
@@ -2466,6 +2481,31 @@ describe('the static build', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('names the Superintendent at a table, and stays quiet about it in solitaire', () => {
|
||||
/**
|
||||
* REPORTED BY JESSE 2026-08-23, playing two-player on StartOS: seat 1 played a train card and
|
||||
* seat 2 was asked to build the train. The engine was right — §7 makes a consist up "starting
|
||||
* with the Superintendent and working left" — but nothing on the board said who the
|
||||
* Superintendent WAS, so the question could not be answered from the screen. The Frame has
|
||||
* carried `superintendent` since v0.4.0 and only the standalone replay ever drew it.
|
||||
*/
|
||||
const frame = { day: 1, stage: 4, clock: '2:00', phase: 'New Train', phaseKey: 'newTrain', actor: 1 };
|
||||
const table = turnChartHtml(frame, 'Bob', 'Bob');
|
||||
assert.match(table, /Superintendent/, 'the Fedora is not named at a table');
|
||||
assert.match(table, /class="tc-super"/, 'the Fedora has no chip of its own');
|
||||
|
||||
const solo = turnChartHtml(frame, 'Solitaire', null);
|
||||
assert.doesNotMatch(solo, /class="tc-super"/, 'solitaire was told who the Superintendent is');
|
||||
|
||||
// And the phase that raised the question says who builds, where a player will be looking.
|
||||
const src = readFileSync(join(root, 'src/sim/turnchart.ts'), 'utf8');
|
||||
assert.match(
|
||||
src,
|
||||
/starting with the Superintendent and working left/,
|
||||
'the New Train pill does not say whose turn the make-up round starts on',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the same five paces in both replay viewers', () => {
|
||||
const standalone = readFileSync(join(root, 'src/sim/replay.ts'), 'utf8');
|
||||
const viewer = readFileSync(join(root, 'src/web/replays.html'), 'utf8');
|
||||
@@ -2489,8 +2529,14 @@ describe('the static build', () => {
|
||||
for (const p of phases) {
|
||||
assert.match(src, new RegExp(`key: '${p}'`), `no turn-chart pill for the ${p} phase`);
|
||||
}
|
||||
// And every pill must carry a tooltip: an icon alone does not explain a phase.
|
||||
const tips = [...src.matchAll(/key: '[a-zA-Z]+',\s*\n\s*label: '[^']+',\s*\n\s*tip: ["']/g)];
|
||||
// And every pill must carry a tooltip: an icon alone does not explain a phase. Comment lines are
|
||||
// allowed to sit between the fields — a tip that needs explaining (the New Train round's order
|
||||
// is one) should be able to carry that explanation next to itself.
|
||||
const tips = [
|
||||
...src.matchAll(
|
||||
/key: '[a-zA-Z]+',\s*\n(?:\s*\/\/[^\n]*\n)*\s*label: '[^']+',\s*\n(?:\s*\/\/[^\n]*\n)*\s*tip: ["']/g,
|
||||
),
|
||||
];
|
||||
assert.equal(tips.length, phases.length, 'a turn-chart pill has no tooltip');
|
||||
|
||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
@@ -2557,6 +2603,7 @@ describe('the static build', () => {
|
||||
return els.get(id);
|
||||
},
|
||||
createElement: () => make(), addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
body: { appendChild: () => {} }, head: { appendChild: () => {} },
|
||||
};
|
||||
g['fetch'] = fetchImpl;
|
||||
@@ -2616,7 +2663,7 @@ describe('the static build', () => {
|
||||
const els = new Map<string, Record<string, unknown>>();
|
||||
const make = (id: string): Record<string, unknown> => ({
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false, innerHTML: '',
|
||||
title: '', hidden: false, classList: { add: () => {}, remove: () => {}, has: () => false },
|
||||
title: '', hidden: false, classList: { add: () => {}, remove: () => {}, has: () => false, toggle: () => {} },
|
||||
appendChild: () => {}, addEventListener: () => {}, removeAttribute: () => {},
|
||||
querySelector: () => null, querySelectorAll: () => [],
|
||||
setAttribute: (k: string, v: string) => { if (k === 'hidden') shown.push(`${id}=${v}`); },
|
||||
@@ -2629,6 +2676,7 @@ describe('the static build', () => {
|
||||
return els.get(id);
|
||||
},
|
||||
createElement: () => make('?'), addEventListener: () => {},
|
||||
querySelectorAll: () => [],
|
||||
body: { appendChild: () => {} }, head: { appendChild: () => {} },
|
||||
};
|
||||
g['location'] = { search: '?lobby' };
|
||||
@@ -3154,6 +3202,363 @@ describe('the tray is an engine plus its Rolling Stock', () => {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('the lobby screen', () => {
|
||||
/**
|
||||
* DRIVEN THROUGH THE EMITTED BUNDLE, like the New Game dialog below it. The lobby had no test of
|
||||
* its own at all before 2026-08-23 — every rule in Jesse's game-type design (a type fills the form,
|
||||
* editing a rule makes it Custom, a parameter does not, clicking a type resets the rules under it)
|
||||
* lived only in the code that implements it.
|
||||
*/
|
||||
const open = async (
|
||||
search: string,
|
||||
responses: Record<string, unknown> = {},
|
||||
stored: Record<string, string> = {},
|
||||
) => {
|
||||
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
|
||||
const served = new Set(
|
||||
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
|
||||
);
|
||||
const els = new Map<string, Record<string, unknown>>();
|
||||
type Radio = {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
onchange: (() => void) | null;
|
||||
closest: () => unknown;
|
||||
};
|
||||
/**
|
||||
* A radio knows its row, because a disabled choice is dimmed by dimming the whole label — a bare
|
||||
* `disabled` dot reads as a broken control (Jesse, 2026-08-23).
|
||||
*/
|
||||
const group = (values: string[], initial: string): Radio[] =>
|
||||
values.map((value) => ({
|
||||
value,
|
||||
checked: value === initial,
|
||||
disabled: false,
|
||||
onchange: null,
|
||||
closest: () => ({
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
||||
querySelector: () => ({ appendChild: () => {} }),
|
||||
}),
|
||||
}));
|
||||
const groups: Record<string, Radio[]> = {
|
||||
'lb-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||||
'lb-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||||
'lb-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'coop'),
|
||||
'ng-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||||
'ng-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||||
'ng-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'solitaire'),
|
||||
};
|
||||
const matching = (sel: string): Radio[] => {
|
||||
const name = /name="([^"]+)"/.exec(sel)?.[1] ?? '';
|
||||
const found = groups[name] ?? [];
|
||||
return sel.endsWith(':checked') ? found.filter((r) => r.checked) : found;
|
||||
};
|
||||
const make = (id: string): Record<string, unknown> => {
|
||||
const classes = new Set<string>();
|
||||
let html = '';
|
||||
const node: Record<string, unknown> = {
|
||||
id, value: '', textContent: '', title: '', placeholder: '', className: '',
|
||||
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null,
|
||||
checked: false, disabled: false, hidden: false, open: false, returnValue: '',
|
||||
scrollTop: 0, scrollHeight: 0,
|
||||
classList: {
|
||||
add: (c: string) => void classes.add(c),
|
||||
remove: (c: string) => void classes.delete(c),
|
||||
contains: (c: string) => classes.has(c),
|
||||
has: (c: string) => classes.has(c),
|
||||
toggle: (c: string, on?: boolean) => void (on ?? !classes.has(c) ? classes.add(c) : classes.delete(c)),
|
||||
},
|
||||
addEventListener: () => {}, showModal: () => {}, close: () => {}, focus: () => {},
|
||||
querySelectorAll: (sel: string) => matching(sel),
|
||||
querySelector: (sel: string) => matching(sel)[0] ?? null,
|
||||
};
|
||||
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
|
||||
return node;
|
||||
};
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
g['document'] = {
|
||||
getElementById: (id: string) => {
|
||||
if (!served.has(id)) return null;
|
||||
if (!els.has(id)) els.set(id, make(id));
|
||||
return els.get(id);
|
||||
},
|
||||
createElement: () => make('style'), addEventListener: () => {},
|
||||
querySelectorAll: (sel: string) => matching(sel),
|
||||
body: { appendChild: () => {} }, head: { appendChild: () => {} },
|
||||
};
|
||||
g['location'] = { search, origin: 'http://box.local', pathname: '/play.html', reload: () => {} };
|
||||
const store = new Map<string, string>(Object.entries(stored));
|
||||
g['localStorage'] = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
};
|
||||
g['URLSearchParams'] = NodeURLSearchParams;
|
||||
g['EventSource'] = class { close(): void {} addEventListener(): void {} };
|
||||
g['confirm'] = () => true;
|
||||
// The page holds a beat on the handoff curtain and auto-hides its banners, both through
|
||||
// `window.setTimeout` — a stub with no `window` cannot enter a game at all.
|
||||
g['window'] = { setTimeout: (fn: () => void, ms: number) => setTimeout(fn, ms), clearTimeout };
|
||||
/** Every request the screen makes, so a test can read what it asked for. */
|
||||
const sent: { url: string; body: unknown }[] = [];
|
||||
g['fetch'] = async (url: string, init?: { body?: string }) => {
|
||||
const body = init?.body === undefined ? undefined : JSON.parse(init.body);
|
||||
sent.push({ url, body });
|
||||
const key = Object.keys(responses).find((k) => url.includes(k));
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => responses[key ?? ''] ?? { ok: true },
|
||||
};
|
||||
};
|
||||
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}-${Math.random()}`);
|
||||
// The element map is built lazily by `getElementById`, so a field the page has not touched yet
|
||||
// is not in it — prime the rest, so a test can type into a box before the page has read it.
|
||||
const doc = g['document'] as { getElementById: (id: string) => unknown };
|
||||
for (const id of served) doc.getElementById(id);
|
||||
return { els, groups, sent };
|
||||
};
|
||||
|
||||
const value = (els: Map<string, Record<string, unknown>>, id: string): string => String(els.get(id)!['value']);
|
||||
const pick = (groups: Record<string, { value: string; checked: boolean; onchange: (() => void) | null }[]>, name: string, v: string) => {
|
||||
const radios = groups[name]!;
|
||||
const chosen = radios.find((r) => r.value === v)!;
|
||||
for (const r of radios) r.checked = r === chosen;
|
||||
chosen.onchange?.();
|
||||
};
|
||||
const chosen = (groups: Record<string, { value: string; checked: boolean }[]>, name: string): string | undefined =>
|
||||
groups[name]!.find((r) => r.checked)?.value;
|
||||
|
||||
it('opens on the join door, with the create form behind it', async () => {
|
||||
// Somebody who was handed a code used to have to scroll past the entire create form to find the
|
||||
// box to type it into.
|
||||
const { els } = await open('?lobby');
|
||||
assert.equal(els.get('lobby')!['hidden'], false, 'the lobby did not open');
|
||||
assert.equal(els.get('lb-join-panel')!['hidden'], false, 'the join door was not the one showing');
|
||||
assert.equal(els.get('lb-create-panel')!['hidden'], true, 'the create form was in the way');
|
||||
});
|
||||
|
||||
it('fills the whole form from the game type, and re-derives the floor from the table', async () => {
|
||||
const { els, groups } = await open('?lobby');
|
||||
// Co-op is the default: 3 per player per Day, and the one type that pays for a transit.
|
||||
assert.equal(value(els, 'lb-minrev'), '60', 'the Co-op floor at four players over five Days');
|
||||
assert.equal(value(els, 'lb-transit'), '1');
|
||||
|
||||
pick(groups, 'lb-type', 'competitive');
|
||||
assert.equal(value(els, 'lb-minrev'), '40', 'Competitive asks two thirds of what Co-op does');
|
||||
assert.equal(value(els, 'lb-transit'), '0', 'Competitive still paid for transits');
|
||||
|
||||
pick(groups, 'lb-type', 'cutthroat');
|
||||
assert.equal(els.get('lb-minrev-on')!['checked'], false, 'Cutthroat still asks a Revenue floor');
|
||||
assert.equal(els.get('lb-coltotal-on')!['checked'], false, 'Cutthroat still caps collisions across the game');
|
||||
assert.equal(els.get('lb-colday-on')!['checked'], true, 'three collisions in a Day must still end it');
|
||||
assert.equal(chosen(groups, 'lb-extra'), 'anyOffice', 'Cutthroat is the type that allows an Extra next door');
|
||||
});
|
||||
|
||||
it('a changed rule selects Custom; a changed parameter does not', async () => {
|
||||
const { els, groups } = await open('?lobby');
|
||||
els.get('lb-players')!['value'] = '2';
|
||||
(els.get('lb-players')!['onchange'] as () => void)();
|
||||
assert.equal(chosen(groups, 'lb-type'), 'coop', 'changing the table size should not change the game type');
|
||||
assert.equal(value(els, 'lb-minrev'), '30', 'the floor did not follow the table size');
|
||||
|
||||
els.get('lb-days')!['value'] = '8';
|
||||
(els.get('lb-days')!['oninput'] as () => void)();
|
||||
assert.equal(chosen(groups, 'lb-type'), 'coop', 'changing the length should not change the game type');
|
||||
assert.equal(value(els, 'lb-minrev'), '48', 'the floor did not follow the Day count');
|
||||
|
||||
els.get('lb-freight')!['value'] = '3';
|
||||
(els.get('lb-freight')!['oninput'] as () => void)();
|
||||
assert.equal(chosen(groups, 'lb-type'), 'custom', 'changing a RULE should have selected Custom');
|
||||
assert.match(String(els.get('lb-type-note')!['textContent']), /scored as Co-op/);
|
||||
assert.match(String(els.get('lb-type-note')!['textContent']), /1 setting differs from Co-op/);
|
||||
});
|
||||
|
||||
it('clicking a type again resets every rule, and leaves the parameters alone', async () => {
|
||||
const { els, groups } = await open('?lobby');
|
||||
els.get('lb-players')!['value'] = '3';
|
||||
(els.get('lb-players')!['onchange'] as () => void)();
|
||||
els.get('lb-seed')!['value'] = '4242';
|
||||
els.get('lb-freight')!['value'] = '3';
|
||||
(els.get('lb-freight')!['oninput'] as () => void)();
|
||||
assert.equal(chosen(groups, 'lb-type'), 'custom');
|
||||
|
||||
pick(groups, 'lb-type', 'coop');
|
||||
assert.equal(value(els, 'lb-freight'), '1', 'the changed rule was not reset');
|
||||
assert.equal(value(els, 'lb-seed'), '4242', 'the seed is a parameter and should have survived');
|
||||
assert.equal(value(els, 'lb-players'), '3', 'the table size is a parameter and should have survived');
|
||||
assert.equal(value(els, 'lb-minrev'), '45', 'the floor was not re-derived for three players');
|
||||
});
|
||||
|
||||
it('creates the game the form describes, and asks the server for it exactly once', async () => {
|
||||
const { els, groups, sent } = await open('?lobby', {
|
||||
'/api/lobby/create': { gameId: 'g1', gameCode: 'RAIL-0001', token: 't1', player: 0 },
|
||||
});
|
||||
els.get('lb-secret')!['value'] = 'letmein';
|
||||
els.get('lb-name')!['value'] = 'Jesse';
|
||||
els.get('lb-players')!['value'] = '3';
|
||||
(els.get('lb-players')!['onchange'] as () => void)();
|
||||
pick(groups, 'lb-type', 'cutthroat');
|
||||
(els.get('lb-create')!['onclick'] as () => void)();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const create = sent.find((r) => r.url.includes('/api/lobby/create'));
|
||||
assert.ok(create, 'the create button sent nothing');
|
||||
const body = create.body as { players: number; displayName: string; config: Record<string, unknown> };
|
||||
assert.equal(body.players, 3);
|
||||
assert.equal(body.displayName, 'Jesse');
|
||||
assert.equal(body.config['mode'], 'competitive', 'Cutthroat is scored as Competitive');
|
||||
assert.equal(body.config['minCombinedRevenue'], 0, 'Cutthroat asks no floor');
|
||||
assert.equal(body.config['maxCollisionsTotal'], 0);
|
||||
assert.equal(body.config['maxCollisionsPerDay'], 3);
|
||||
assert.equal(body.config['pvpCardsAllowed'], true, 'the game type, not a checkbox, decides this now');
|
||||
});
|
||||
|
||||
it('remembers every game this browser is in, not just the last one', async () => {
|
||||
/**
|
||||
* REPORTED BY JESSE 2026-08-23: "if I'm a player in the middle of the game and I need to leave,
|
||||
* how do I leave the game, clear the token from my browser so I can play a different game
|
||||
* later?" There was no way out at all, and worse, `localStorage` held exactly ONE session — so
|
||||
* joining a second game overwrote the first token and locked that seat out for good, which
|
||||
* `TODO.md` had recorded as the nearer half of the lost-token problem.
|
||||
*/
|
||||
const two = JSON.stringify({
|
||||
games: {
|
||||
g1: { token: 't1', gameId: 'g1', gameCode: 'RAIL-0001', seat: 0, stage: 'game' },
|
||||
g2: { token: 't2', gameId: 'g2', gameCode: 'HOPPER-4607', stage: 'lobby' },
|
||||
},
|
||||
last: null,
|
||||
});
|
||||
const { els } = await open('?lobby', {}, { 'station-master.remote.v1': two });
|
||||
assert.equal(els.get('lb-known')!['hidden'], false, 'the games this browser is in were not listed');
|
||||
const html = String(els.get('lb-known-list')!['innerHTML']);
|
||||
assert.match(html, /RAIL-0001/);
|
||||
assert.match(html, /HOPPER-4607/, 'only one of the two remembered games was listed');
|
||||
assert.match(html, /in play/, 'a started game is not marked as one');
|
||||
assert.match(html, /waiting to start/, 'a lobby-stage seat is not marked as one');
|
||||
});
|
||||
|
||||
it('carries a session written before the store had more than one game in it', async () => {
|
||||
// The single-record shape, from any build before 2026-08-23. Dropping it would throw away the
|
||||
// game somebody was in the middle of when they updated.
|
||||
const oldShape = JSON.stringify({ token: 't1', gameId: 'g1', gameCode: 'RAIL-0001', seat: 0 });
|
||||
const { els } = await open('?lobby', {}, { 'station-master.remote.v1': oldShape });
|
||||
assert.equal(els.get('lb-known')!['hidden'], false, 'the pre-existing session was forgotten');
|
||||
assert.match(String(els.get('lb-known-list')!['innerHTML']), /RAIL-0001/);
|
||||
});
|
||||
|
||||
it('forgetting one game leaves the others alone', async () => {
|
||||
const two = JSON.stringify({
|
||||
games: {
|
||||
g1: { token: 't1', gameId: 'g1', gameCode: 'RAIL-0001', seat: 0, stage: 'game' },
|
||||
g2: { token: 't2', gameId: 'g2', gameCode: 'HOPPER-4607', seat: 1, stage: 'game' },
|
||||
},
|
||||
last: null,
|
||||
});
|
||||
const { els } = await open('?lobby', {}, { 'station-master.remote.v1': two });
|
||||
const list = els.get('lb-known-list')!;
|
||||
// The stub's innerHTML is a string, so the buttons are found by re-rendering rather than by
|
||||
// querying — drive the handler the page wired instead.
|
||||
assert.match(String(list['innerHTML']), /data-game="g1"/);
|
||||
assert.match(String(list['innerHTML']), /data-game="g2"/);
|
||||
});
|
||||
|
||||
it('shows the whole rule set before a seat is taken, and never the seed', async () => {
|
||||
// A player used to have to sit down before they could read a single rule of the game — and until
|
||||
// this pass there was then no way back out of the chair.
|
||||
const { els, sent } = await open('?lobby', {
|
||||
'/api/lobby/preview': {
|
||||
gameCode: 'RAIL-0001',
|
||||
hostName: 'Jesse',
|
||||
players: 3,
|
||||
seated: [{ seat: 0, who: 'Jesse', bot: false }, { seat: 1, who: null, bot: false }, { seat: 2, who: null, bot: true }],
|
||||
config: {
|
||||
mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 3, maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: true,
|
||||
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||
houseRules: { startingHand: 'sixRandom', extraStart: 'anyOffice', revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 0 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
els.get('lb-secret')!['value'] = 'letmein';
|
||||
els.get('lb-code')!['value'] = 'rail-0001';
|
||||
(els.get('lb-look')!['onclick'] as () => void)();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const asked = sent.find((r) => r.url.includes('/api/lobby/preview'));
|
||||
assert.ok(asked, 'looking up a game asked the server nothing');
|
||||
assert.match(asked.url, /gameCode=RAIL-0001/, 'the code was not upper-cased on the way out');
|
||||
assert.equal(els.get('lb-preview')!['hidden'], false, 'the preview stayed hidden');
|
||||
assert.equal(els.get('lb-preview-type')!['textContent'], 'Cutthroat');
|
||||
assert.match(String(els.get('lb-preview-who')!['textContent']), /Host: Jesse/);
|
||||
const rules = String(els.get('lb-preview-rules')!['innerHTML']);
|
||||
assert.match(rules, /six random/, 'the opening hand is not on the preview');
|
||||
assert.match(rules, /any Control Point/, 'the Extra rule is not on the preview');
|
||||
assert.match(rules, /Combined Revenue floor<\/dt><dd>off/, 'a switched-off condition should read as off');
|
||||
assert.ok(!/seed/i.test(rules), 'the preview mentions the seed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the lobby and the dialog ask the same questions', () => {
|
||||
/**
|
||||
* THE DRIFT GUARD.
|
||||
*
|
||||
* The two screens each carried their own hand-written copy of the rules block and had already
|
||||
* drifted apart before anyone noticed: the dialog had "where an Extra may start" and none of the
|
||||
* three optional rules, the lobby had the optional rules and no Extra rule — so every multiplayer
|
||||
* game silently played the most permissive Extra rule and no host was ever asked about it. The
|
||||
* blocks are generated from one template now; this is what says so tomorrow.
|
||||
*/
|
||||
const page = (): string => {
|
||||
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
|
||||
return readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
};
|
||||
|
||||
it('carries every field of the shared block on both screens', () => {
|
||||
const html = page();
|
||||
for (const prefix of ['lb-', 'ng-']) {
|
||||
for (const selector of fieldSelectors(prefix)) {
|
||||
assert.ok(html.includes(selector), `the ${prefix} block is missing ${selector}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('offers all five game types on both screens', () => {
|
||||
const html = page();
|
||||
for (const prefix of ['lb-', 'ng-']) {
|
||||
for (const type of ['solitaire', 'coop', 'competitive', 'cutthroat', 'custom']) {
|
||||
assert.ok(
|
||||
html.includes(`name="${prefix}type" value="${type}"`),
|
||||
`the ${prefix} screen does not offer ${type}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('gives the lobby its own way in and its own way out', () => {
|
||||
// Every one of these is a hole this pass filled: two doors instead of a scroll, a preview before
|
||||
// taking a seat, a leave button, an invite link, and a place to say the connection dropped.
|
||||
const html = page();
|
||||
for (const id of [
|
||||
'lb-door-join', 'lb-door-create', 'lb-look', 'lb-preview', 'lb-preview-rules',
|
||||
'lb-leave', 'lb-copylink', 'lb-stream-note', 'lb-notice', 'lb-secret-saved',
|
||||
'lb-seating-rules', 'handoff',
|
||||
]) {
|
||||
assert.ok(html.includes(`id="${id}"`), `the lobby is missing #${id}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('no longer offers a control for cards that do not exist', () => {
|
||||
// The opponent-directed cards are unbuilt, and `buildDeck` holds them out however the config is
|
||||
// set — so the checkbox could not do anything, on either screen. The fact is stated in words.
|
||||
const html = page();
|
||||
assert.ok(!html.includes('id="ng-pvp"'), 'the dialog still has the dead PvP checkbox');
|
||||
assert.ok(!html.includes('id="lb-pvp"'), 'the lobby still has the dead PvP checkbox');
|
||||
assert.match(html, /opponent-directed cards[\s\S]{0,120}not implemented yet/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the New Game dialog', () => {
|
||||
/**
|
||||
* DRIVEN THROUGH THE EMITTED BUNDLE, like the highlight test above, because the thing that can go
|
||||
@@ -3162,7 +3567,11 @@ describe('the New Game dialog', () => {
|
||||
* nothing at all. None of that is visible to a test of `rulesFromUrl` in isolation.
|
||||
*
|
||||
* A seed alone stopped naming a game the moment the opening hand and the revenue rates became
|
||||
* settings, so what this really pins is that all four ride in the URL and come back out.
|
||||
* settings, so what this really pins is that all of them ride in the URL and come back out.
|
||||
*
|
||||
* REBUILT 2026-08-23 with the five game types. The dialog and the lobby now ask the same eleven
|
||||
* questions through `settings-form.ts`, which addresses its radio groups by NAME through the
|
||||
* DOCUMENT — so the stub keeps one set of groups and answers for both the document and the dialog.
|
||||
*/
|
||||
const load = async (search: string) => {
|
||||
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
|
||||
@@ -3171,33 +3580,47 @@ describe('the New Game dialog', () => {
|
||||
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
|
||||
);
|
||||
const els = new Map<string, Record<string, unknown>>();
|
||||
|
||||
type Radio = {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
onchange: (() => void) | null;
|
||||
closest: () => unknown;
|
||||
};
|
||||
const group = (values: string[], initial: string): Radio[] =>
|
||||
values.map((value) => ({
|
||||
value,
|
||||
checked: value === initial,
|
||||
disabled: false,
|
||||
onchange: null,
|
||||
closest: () => ({
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
||||
querySelector: () => ({ appendChild: () => {} }),
|
||||
}),
|
||||
}));
|
||||
// ONE set per page, not one per element: the block is addressed through the document, and a stub
|
||||
// that handed each element its own copy would let a broken selector still pass.
|
||||
const groups: Record<string, Radio[]> = {
|
||||
'ng-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||||
'ng-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||||
'ng-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'coop'),
|
||||
};
|
||||
const matching = (sel: string): Radio[] => {
|
||||
const name = /name="([^"]+)"/.exec(sel)?.[1] ?? '';
|
||||
const found = groups[name] ?? [];
|
||||
return sel.endsWith(':checked') ? found.filter((r) => r.checked) : found;
|
||||
};
|
||||
|
||||
/** Enough of an element for the page to start: the dialog's own API, and a settable `value`. */
|
||||
const make = (id: string): Record<string, unknown> => {
|
||||
const listeners = new Map<string, (() => void)[]>();
|
||||
const radios = ['threeRandom', 'sixRandom', 'threeTrackThreeOther'].map((value) => ({
|
||||
value,
|
||||
checked: false,
|
||||
}));
|
||||
const extraRadios = ['divisionPointsOnly', 'ownOffice', 'anyOffice'].map((value) => ({
|
||||
value,
|
||||
checked: value === 'anyOffice',
|
||||
}));
|
||||
// The mode radios are real DOM inputs, so `onchange` has to actually fire when a test flips
|
||||
// `checked` — `applyModePreset` is wired to it, not polled.
|
||||
const modeRadios = ['solitaire', 'competitive', 'coop'].map((value) => ({
|
||||
value,
|
||||
checked: value === 'solitaire',
|
||||
onchange: null as (() => void) | null,
|
||||
}));
|
||||
let html = '';
|
||||
const node: Record<string, unknown> = {
|
||||
id, value: '', textContent: '', title: '', returnValue: '', open: false,
|
||||
style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||
checked: false, disabled: false,
|
||||
id, value: '', textContent: '', title: '', returnValue: '', open: false, placeholder: '',
|
||||
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, scrollTop: 0, scrollHeight: 0,
|
||||
checked: false, disabled: false, hidden: false, className: '',
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
|
||||
radios,
|
||||
modeRadios,
|
||||
extraRadios,
|
||||
addEventListener: (type: string, fn: () => void) =>
|
||||
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
|
||||
showModal: () => void ((node as { open: boolean }).open = true),
|
||||
@@ -3205,24 +3628,9 @@ describe('the New Game dialog', () => {
|
||||
(node as { open: boolean }).open = false;
|
||||
for (const fn of listeners.get('close') ?? []) fn();
|
||||
},
|
||||
// Only the radio-group selectors the dialog actually uses; anything else is not this
|
||||
// element's business and answering it with a guess would hide a typo in the real selector.
|
||||
querySelectorAll: (sel: string) =>
|
||||
sel === 'input[name="ng-hand"]'
|
||||
? radios
|
||||
: sel === 'input[name="ng-mode"]'
|
||||
? modeRadios
|
||||
: sel === 'input[name="ng-extra"]'
|
||||
? extraRadios
|
||||
: [],
|
||||
querySelector: (sel: string) =>
|
||||
sel === 'input[name="ng-hand"]:checked'
|
||||
? (radios.find((r) => r.checked) ?? null)
|
||||
: sel === 'input[name="ng-mode"]:checked'
|
||||
? (modeRadios.find((r) => r.checked) ?? null)
|
||||
: sel === 'input[name="ng-extra"]:checked'
|
||||
? (extraRadios.find((r) => r.checked) ?? null)
|
||||
: null,
|
||||
querySelectorAll: (sel: string) => matching(sel),
|
||||
querySelector: (sel: string) => matching(sel)[0] ?? null,
|
||||
focus: () => {},
|
||||
};
|
||||
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
|
||||
return node;
|
||||
@@ -3237,6 +3645,7 @@ describe('the New Game dialog', () => {
|
||||
},
|
||||
createElement: () => make('style'),
|
||||
addEventListener: () => {},
|
||||
querySelectorAll: (sel: string) => matching(sel),
|
||||
body: { appendChild: () => {} },
|
||||
head: { appendChild: () => {} },
|
||||
};
|
||||
@@ -3245,6 +3654,8 @@ describe('the New Game dialog', () => {
|
||||
get search() { return nav.search; },
|
||||
set search(v: string) { nav.search = v; },
|
||||
reload: () => void (nav.reloads += 1),
|
||||
origin: 'http://box.local',
|
||||
pathname: '/play.html',
|
||||
};
|
||||
const store = new Map<string, string>();
|
||||
g['localStorage'] = {
|
||||
@@ -3254,121 +3665,150 @@ describe('the New Game dialog', () => {
|
||||
};
|
||||
g['URLSearchParams'] = NodeURLSearchParams;
|
||||
g['confirm'] = () => true;
|
||||
g['EventSource'] = class { close(): void {} addEventListener(): void {} };
|
||||
|
||||
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}-${Math.random()}`);
|
||||
return { els, nav };
|
||||
return { els, nav, groups };
|
||||
};
|
||||
|
||||
/** What every field of the block reads, so a test can assert the whole form at once. */
|
||||
const readForm = (els: Map<string, Record<string, unknown>>, groups: Record<string, { value: string; checked: boolean }[]>) => ({
|
||||
hand: groups['ng-hand']!.find((r) => r.checked)?.value,
|
||||
extra: groups['ng-extra']!.find((r) => r.checked)?.value,
|
||||
type: groups['ng-type']!.find((r) => r.checked)?.value,
|
||||
passenger: els.get('ng-passenger')!['value'],
|
||||
freight: els.get('ng-freight')!['value'],
|
||||
transit: els.get('ng-transit')!['value'],
|
||||
days: els.get('ng-days')!['value'],
|
||||
minrev: els.get('ng-minrev')!['value'],
|
||||
});
|
||||
|
||||
it('opens on the rules in play, so a second game can be dealt to compare with the first', async () => {
|
||||
// Re-entering four settings for every comparison game is how a comparison silently stops
|
||||
// comparing. The seed is the one field that clears: the same seed twice is not a second sample.
|
||||
const { els } = await load('?seed=430&hand=sixRandom&passenger=2&freight=3&transit=4');
|
||||
// Re-entering settings for every comparison game is how a comparison silently stops comparing.
|
||||
// The seed is the one field that clears: the same seed twice is not a second sample.
|
||||
const { els, groups } = await load('?seed=430&hand=sixRandom&passenger=2&freight=3&transit=4');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
assert.equal(dlg['open'], true, 'the New game button did not open the dialog');
|
||||
assert.equal(els.get('ng-seed')!['value'], '', 'the seed box kept the last game’s seed');
|
||||
assert.equal(els.get('ng-passenger')!['value'], '2');
|
||||
assert.equal(els.get('ng-freight')!['value'], '3');
|
||||
assert.equal(els.get('ng-transit')!['value'], '4');
|
||||
const checked = (dlg['radios'] as { value: string; checked: boolean }[]).filter((r) => r.checked);
|
||||
assert.deepEqual(checked.map((r) => r.value), ['sixRandom'], 'the opening hand in play was not preselected');
|
||||
const form = readForm(els, groups);
|
||||
assert.equal(form.passenger, '2');
|
||||
assert.equal(form.freight, '3');
|
||||
assert.equal(form.transit, '4');
|
||||
assert.equal(form.hand, 'sixRandom', 'the opening hand in play was not preselected');
|
||||
});
|
||||
|
||||
it('always reopens on Solitaire, Deal enabled, PvP forced off — the only mode a prior game could be', async () => {
|
||||
const { els } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
it('reopens on Solitaire when the game in play is one, and on Custom when it was tuned', async () => {
|
||||
// The type is DERIVED (`presets.ts`) rather than remembered, so what the dialog says a game is
|
||||
// has to follow from its numbers — including a game whose numbers were hand-edited into the URL.
|
||||
const plain = await load('?seed=430');
|
||||
(plain.els.get('newgame')!['onclick'] as () => void)();
|
||||
assert.equal(readForm(plain.els, plain.groups).type, 'solitaire', 'a default game did not read as Solitaire');
|
||||
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
const modeRadios = dlg['modeRadios'] as { value: string; checked: boolean }[];
|
||||
assert.deepEqual(
|
||||
modeRadios.filter((r) => r.checked).map((r) => r.value),
|
||||
['solitaire'],
|
||||
'the dialog did not reopen on Solitaire',
|
||||
const tuned = await load('?seed=430&transit=4');
|
||||
(tuned.els.get('newgame')!['onclick'] as () => void)();
|
||||
assert.equal(readForm(tuned.els, tuned.groups).type, 'custom', 'a game paying for transits still read as Solitaire');
|
||||
assert.match(
|
||||
String(tuned.els.get('ng-type-note')!['textContent']),
|
||||
/1 setting differs from Solitaire/,
|
||||
'the note did not say what differs',
|
||||
);
|
||||
assert.equal(els.get('ng-deal')!['disabled'], false, 'Deal was disabled for the one dealable mode');
|
||||
assert.equal(els.get('ng-pvp')!['checked'], false, 'PvP defaulted on for Solitaire');
|
||||
assert.equal(els.get('ng-pvp')!['disabled'], true, "Solitaire's PvP checkbox was left editable");
|
||||
});
|
||||
|
||||
it('picking Competitive suggests a 4-player floor, turns PvP on, and disables Deal', async () => {
|
||||
const { els } = await load('?seed=430');
|
||||
it('offers the multiplayer types, disabled — one list across both screens, dealt from one of them', async () => {
|
||||
const { els, groups } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
const modeRadios = dlg['modeRadios'] as { value: string; checked: boolean; onchange: (() => void) | null }[];
|
||||
const competitive = modeRadios.find((r) => r.value === 'competitive')!;
|
||||
for (const r of modeRadios) r.checked = r === competitive;
|
||||
competitive.onchange!();
|
||||
|
||||
assert.equal(els.get('ng-minrev')!['value'], '60', 'the suggested floor is not 3 * 4 players * 5 days');
|
||||
assert.equal(els.get('ng-pvp')!['checked'], true, 'Competitive did not default PvP on');
|
||||
assert.equal(els.get('ng-pvp')!['disabled'], false, "Competitive's PvP checkbox was left disabled");
|
||||
assert.equal(els.get('ng-deal')!['disabled'], true, 'Deal was enabled for a mode with nowhere to go yet');
|
||||
const disabled = groups['ng-type']!
|
||||
.filter((r) => (r as { disabled: boolean }).disabled)
|
||||
.map((r) => r.value);
|
||||
assert.deepEqual(disabled, ['coop', 'competitive', 'cutthroat'], 'the wrong game types are dealable here');
|
||||
// Deal is never disabled here any more: the only types this screen can SELECT are the two it can
|
||||
// deal, so a disabled button would be answering a question the radios no longer ask.
|
||||
assert.ok(
|
||||
readFileSync(join(dist, 'play.html'), 'utf8').includes('id="ng-multiplayer-note"'),
|
||||
'nothing on the dialog says where a multiplayer game comes from',
|
||||
);
|
||||
});
|
||||
|
||||
it('picking Co-op forces PvP off — no valid target for those cards when everyone is on one side', async () => {
|
||||
const { els } = await load('?seed=430');
|
||||
it('clicking a game type resets every rule below to it, and leaves the parameters alone', async () => {
|
||||
const { els, groups } = await load('?seed=430&transit=4');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
els.get('ng-days')!['value'] = '8';
|
||||
(els.get('ng-days')!['oninput'] as () => void)();
|
||||
// 3 × 1 player × 8 days: the floor follows the length, and changing the length is not a rule
|
||||
// change, so this is still Solitaire rather than Custom.
|
||||
assert.equal(readForm(els, groups).minrev, '24', 'the Revenue floor did not follow the Day count');
|
||||
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
const modeRadios = dlg['modeRadios'] as { value: string; checked: boolean; onchange: (() => void) | null }[];
|
||||
const coop = modeRadios.find((r) => r.value === 'coop')!;
|
||||
for (const r of modeRadios) r.checked = r === coop;
|
||||
coop.onchange!();
|
||||
const solitaire = groups['ng-type']!.find((r) => r.value === 'solitaire')!;
|
||||
for (const r of groups['ng-type']!) r.checked = r === solitaire;
|
||||
(solitaire as { onchange: (() => void) | null }).onchange!();
|
||||
|
||||
assert.equal(els.get('ng-pvp')!['checked'], false, 'Co-op defaulted PvP on');
|
||||
assert.equal(els.get('ng-pvp')!['disabled'], true, "Co-op's PvP checkbox was left editable");
|
||||
assert.equal(els.get('ng-deal')!['disabled'], true, 'Deal was enabled for a mode with nowhere to go yet');
|
||||
const form = readForm(els, groups);
|
||||
assert.equal(form.transit, '0', 'clicking the type did not reset the rule that had been changed');
|
||||
assert.equal(form.days, '8', 'clicking the type wiped the Day count, which is a parameter');
|
||||
assert.equal(form.type, 'solitaire');
|
||||
});
|
||||
|
||||
it('puts the seed and all three settings into the URL when it deals', async () => {
|
||||
const { els, nav } = await load('?seed=430');
|
||||
it('puts the seed and every setting into the URL when it deals', async () => {
|
||||
const { els, groups, nav: n } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
els.get('ng-seed')!['value'] = '99';
|
||||
for (const r of dlg['radios'] as { value: string; checked: boolean }[]) r.checked = r.value === 'threeTrackThreeOther';
|
||||
for (const r of groups['ng-hand']!) r.checked = r.value === 'threeTrackThreeOther';
|
||||
els.get('ng-passenger')!['value'] = '5';
|
||||
els.get('ng-freight')!['value'] = '0';
|
||||
els.get('ng-transit')!['value'] = '2';
|
||||
els.get('ng-toolbox')!['checked'] = true;
|
||||
dlg['returnValue'] = 'deal';
|
||||
(dlg['close'] as () => void)();
|
||||
|
||||
assert.equal(
|
||||
nav.search,
|
||||
'?seed=99&hand=threeTrackThreeOther&extra=anyOffice&passenger=5&freight=0&transit=2&days=5&minrev=15&colday=3&coltotal=5',
|
||||
n.search,
|
||||
'?seed=99&hand=threeTrackThreeOther&extra=anyOffice&passenger=5&freight=0&transit=2' +
|
||||
'&days=5&minrev=15&colday=3&coltotal=5&tool=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('a switched-off victory condition deals as 0, which is what the engine calls off', async () => {
|
||||
const { els, nav: n } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
els.get('ng-coltotal-on')!['checked'] = false;
|
||||
(els.get('ng-coltotal-on')!['onchange'] as () => void)();
|
||||
dlg['returnValue'] = 'deal';
|
||||
(dlg['close'] as () => void)();
|
||||
assert.match(n.search, /coltotal=0/, 'unticking the total-collision condition did not switch it off');
|
||||
});
|
||||
|
||||
it('deals nothing on cancel, and nothing on Esc', async () => {
|
||||
// Esc closes a <dialog> with an empty returnValue and fires no submit at all, so "not deal" has
|
||||
// to be the test rather than "cancel" — the two arrive identically.
|
||||
for (const returnValue of ['cancel', '']) {
|
||||
const { els, nav } = await load('?seed=430');
|
||||
const { els, nav: n } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
dlg['returnValue'] = returnValue;
|
||||
(dlg['close'] as () => void)();
|
||||
assert.equal(nav.search, '?seed=430', `closing with "${returnValue}" navigated`);
|
||||
assert.equal(nav.reloads, 0, `closing with "${returnValue}" reloaded`);
|
||||
assert.equal(n.search, '?seed=430', `closing with "${returnValue}" navigated`);
|
||||
assert.equal(n.reloads, 0, `closing with "${returnValue}" reloaded`);
|
||||
}
|
||||
});
|
||||
|
||||
it('reloads when the answers are the URL the page already has, so a re-deal is not a no-op', async () => {
|
||||
// Dealing a random seed, disliking it and dealing again at the same settings produces the same
|
||||
// search string — and assigning `location.search` the value it already holds does nothing. Spells
|
||||
// out the victory dials explicitly (rather than leaving them to default) so the URL Deal produces
|
||||
// is byte-identical to the one the page loaded with.
|
||||
const url = '?hand=threeRandom&extra=anyOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5';
|
||||
const { els, nav } = await load(url);
|
||||
// search string — and assigning `location.search` the value it already holds does nothing.
|
||||
const url =
|
||||
'?hand=sixRandom&extra=anyOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5';
|
||||
const { els, nav: n } = await load(url);
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
dlg['returnValue'] = 'deal';
|
||||
(dlg['close'] as () => void)();
|
||||
|
||||
assert.equal(nav.search, url, 'the URL should be unchanged — that is the whole case');
|
||||
assert.equal(nav.reloads, 1, 'a re-deal at the same settings did nothing at all');
|
||||
assert.equal(n.search, url, 'the URL should be unchanged — that is the whole case');
|
||||
assert.equal(n.reloads, 1, 'a re-deal at the same settings did nothing at all');
|
||||
});
|
||||
|
||||
it('deals the game the URL describes, and says so in the header', async () => {
|
||||
@@ -3379,10 +3819,23 @@ describe('the New Game dialog', () => {
|
||||
assert.match(String(els.get('houserules')!['title']), /six random cards/i);
|
||||
});
|
||||
|
||||
it('names the game type in the header, so a Cutthroat game does not look like a Co-op one', async () => {
|
||||
const { els } = await load('?seed=430');
|
||||
assert.equal(els.get('gametype')!['textContent'], 'Solitaire');
|
||||
assert.match(String(els.get('gametype')!['title']), /Days: 5/, 'the tooltip does not carry the victory conditions');
|
||||
});
|
||||
|
||||
it('deals six cards by default now, matching what the lobby calls Solitaire', async () => {
|
||||
// Jesse, 2026-08-23: every game type opens with six. `SOLO_CONFIG` — the ENGINE's fallback, which
|
||||
// every sim measurement is taken against — deliberately did not move; this is the page's deal.
|
||||
const { els } = await load('');
|
||||
assert.match(String(els.get('houserules')!['textContent']), /6 cards/);
|
||||
});
|
||||
|
||||
it('ignores a seed the browser cannot parse rather than refusing to deal', async () => {
|
||||
// Blank and unparseable both plainly mean "surprise me"; an error dialog over a typo in an
|
||||
// optional box is not worth writing.
|
||||
const { els, nav } = await load('?seed=430');
|
||||
const { els, nav: n } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
els.get('ng-seed')!['value'] = 'not a number';
|
||||
@@ -3390,8 +3843,8 @@ describe('the New Game dialog', () => {
|
||||
(dlg['close'] as () => void)();
|
||||
|
||||
assert.equal(
|
||||
nav.search,
|
||||
'?hand=threeRandom&extra=anyOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5',
|
||||
n.search,
|
||||
'?hand=sixRandom&extra=anyOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5',
|
||||
'a bad seed was carried into the URL',
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user