Compare commits

..
2 Commits
Author SHA1 Message Date
Jesse bfd2708ecc v0.5.5 — a remembered session for a game that no longer exists
Reported after updating to v0.5.4: clicking Multiplayer went straight into
a game with no lobby and no controls, and the board was blank.

Three things lined up. start() enters a remembered session WITHOUT checking
it still exists — that is what makes reconnection seamless, and it is why
the lobby was skipped. The v0.5.4 update had refused to resume that game,
its save being recorded under v0.5.3 and the engine-version check being
exact (D7). And createRemoteSession had no onerror at all, so EventSource
retried the resulting 404 forever in silence while frame stayed null and
nothing rendered. The only escape was clearing site data, and nothing on
screen said so.

v0.5.3's Manage Game -> End had just widened the same dead end: it closes
every watcher's stream, so a player whose game an administrator ended would
sit frozen on a stale board indefinitely, for exactly the same reason.

GET /api/session?token= is new: a cheap yes/no on whether a token still
names a live game. EventSource fires error identically for a transient blip
— the expected shape of a game idle for minutes (§9) — and for a 404 it
will retry forever, and exposes no status code either way, so the client
asks rather than guessing. Only a definite 404 closes the stream and
reports the game gone; a flaky network still self-heals.

The page then forgets the stored session, says why (ended by an
administrator, or the service was updated, which does not carry games
across), and drops into the lobby. Forgetting the token is what stops the
next load repeating it. It also stops rendering nothing while it waits —
"… connecting to the game" sits in the presence banner until the first push
arrives, because a page showing nothing is indistinguishable from a broken
one, which is what this looked like.

Recorded but NOT fixed, in TODO.md: three releases in a row destroyed every
game in progress, and v0.5.4's changes were rendering only. The refusal is
right, but the test is exact equality against the PACKAGE version, which
moves for reasons unrelated to the rules. Three options costed; the
recommendation is to replay the save and refuse only if an intent actually
rejects — the real question rather than a proxy for it, and a full replay
measures ~100 ms.

Verified live: /api/session answers 200 for a seated token, 404 once an
administrator ends the game, 404 for a garbage token, and /api/stream 404s
in the same state — which is the response EventSource had been retrying
silently. 673 tests pass.
2026-08-21 17:55:59 -04:00
Jesse 689de2ff0f v0.5.4 — the map says whose railroad is whose
Six things found playing the StartOS build, all of them the game telling
you what it already knew.

The lobby's Start button did not look disabled when it was. The reported
symptom was "it says it's waiting for a player but Start is enabled" — it
wasn't: the note and the disabled assignment are two lines apart in the
same block. The page had only `header button:disabled` and `#actions
button:disabled`, and #lb-start is in neither, so a disabled button kept
its normal face AND still lit up under the cursor from the generic
button:hover. It advertised a click it would refuse. The rule is generic
now.

The game code was rendered as "— code TRESTLE-5109" in dim text beside a
heading, reading like a reference number rather than the thing you have to
send somebody. It is a labelled block at 22px with a Copy button, and a
clipboard refusal says the code can be selected instead of failing
silently. The blurb under it was also WRONG — it claimed the chairs were
"in the order everyone joined", which stopped being true in v0.4.1 when the
§4.4 D12 started deciding. It now says what actually happens.

Every Office on the Division map was labelled with its tier, which every
other player's Office also has, so four districts read identically and
"where does Bob sit" had no answer on the one map showing where trains are.
The owner's name takes the headline and the tier moves beside the A/D
count. Amber marks whose move it is — the same "happening here" the action
panel uses — and "(you)" is spelled out on the reader's own district,
because colour alone cannot say which of four railroads is yours. Turn
colour wins over the you-colour when both apply: whose turn it is changes
every few seconds, which railroad is yours never does.

Under the map, the chain in words with the roll behind it: "West to East:
Alice (1) → Bot 2 (5) → Bot 1 (11)". state.openingRolls has been kept for
exactly this since v0.4.1 and nothing had displayed it. It also answers
"is the host always at the eastern end" outright — no. Alice there is the
host, rolled lowest, and sits at the western end.

Supporting: Frame gained viewer and viewerSeat. Every private field on it
was already scoped to one player, but nothing said which player, so a page
could draw a railroad without being able to say whose it was — harmless in
solitaire, the first question at four seats. Frame also gained
openingRolls. Bots are Bot 1 / Bot 2 rather than all Bot, since two of them
are two different railroads. The standalone replay gets all of it: players,
actor and viewer are not delta'd keys in compress, so they ride whole on
every frame and replay.ts passes the same roster.

Verified: 673 tests pass (668 + 5). The new ones were mutation-checked —
removing the (you) suffix, never applying the turn mark, and reinstating
the pre-v0.4.1 identity seating each fail the suite. The seating test
deliberately asserts across six seeds that the eastern end is NOT always
player 0, which is the claim it exists to defend.
2026-08-21 17:12:25 -04:00
14 changed files with 510 additions and 21 deletions
+105
View File
@@ -19,6 +19,111 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
--- ---
## 0.5.5 — 2026-08-21
One bug, found by updating to v0.5.4 and clicking Multiplayer: the page went straight into a game
with no lobby and no controls, and the board was blank.
### A remembered session for a game the server no longer has
Three things lined up. `start()` enters a remembered multiplayer session **without checking it
still exists** — that is what makes reconnection seamless, and it is why the lobby was skipped.
The v0.5.4 update had **refused to resume** that game, because the save was recorded under v0.5.3
and the engine-version check is exact (D7). And `createRemoteSession` had **no `onerror` at all**,
so `EventSource` retried the resulting 404 forever, in silence, while `frame` stayed null and the
page rendered nothing.
The only escape was clearing site data, and nothing on screen said so.
The same dead end had just been widened by v0.5.3's **Manage Game → End**, which closes every
watcher's stream: a player whose game an administrator ended would sit frozen on a stale board
indefinitely, for the same reason.
**The fix.** `GET /api/session?token=…` is new — a cheap yes/no on whether a token still names a
live game. `EventSource` fires `error` identically for a transient blip (the expected shape of a
game idle for minutes, §9) and for a 404 it will retry forever, and exposes no status code either
way, so the client asks. Only a definite 404 closes the stream and reports the game gone; a flaky
network still self-heals as before.
The page then forgets the stored session, says why — ended by an administrator, or the service was
updated, which does not carry games across — and drops into the lobby. Forgetting the token is what
stops the next load repeating it.
It also stops rendering nothing while it waits: "… connecting to the game" sits in the presence
banner until the first push arrives, because a page showing nothing is indistinguishable from a
page that is broken, which is precisely what this looked like.
### Recorded, not fixed
`TODO.md` now carries the underlying problem: **three releases in a row destroyed every game in
progress, and v0.5.4's changes were rendering only.** The refusal is right — a move legal under old
rules may not be legal under new ones — but the test is exact equality against the *package*
version, which moves for reasons that have nothing to do with the rules. Three options are costed
there; the recommendation is to replay the save and refuse only if an intent actually rejects,
since that answers the real question rather than a proxy for it, and a full replay measures ~100 ms.
---
## 0.5.4 — 2026-08-21
Six things found by playing the StartOS build, all of them about the game telling you what it
already knows.
### A disabled button that did not look disabled
Reported as "the Start button is enabled when it says it is waiting for a player". It was not — the
note and the `disabled` assignment are two lines apart in the same block, so a lobby waiting on a
chair had a genuinely disabled button. The page had only two `:disabled` rules, `header button` and
`#actions button`, and `#lb-start` is in neither, so it kept its normal face **and** still lit up
under the cursor from the generic `button:hover`. It was advertising a click it would refuse. The
rule is generic now.
### The game code is the invitation
It was rendered as `— code TRESTLE-5109` beside the "Seating" heading, in dim text, reading like a
reference number rather than the thing you have to send someone. It is now a labelled block —
"Send this code to your players" — at 22px, with a Copy button beside it. Clipboard access is
unavailable on an insecure origin and can be refused outright, so a failure says the code can be
selected instead of silently doing nothing.
The blurb under it was also **wrong**: it said the chairs were "West to East, in the order everyone
joined", which has not been true since v0.4.1. §4.4's D12 decides, at start, and the lobby now says
so rather than claiming the opposite.
### The Division map names its districts
Every Office was labelled with its tier, which every other player's Office also has, so four
districts read identically and "where does Bob sit?" had no answer on the only map that shows where
trains are. The owner's name takes the headline and the tier moves down beside the A/D count,
because the name is what is being looked for and the tier is what it is called once found.
Two marks on top of that: **amber for whose move it is**, the same "it is happening here" the
action panel uses, and **"(you)"** spelled out on the reader's own district. Colour alone cannot
say which of four railroads is yours, and that is the first thing you want at a table you have just
sat down at. Where both apply, the turn colour wins — whose turn it is changes every few seconds
and which railroad is yours never does.
Underneath the map, the chain in words with the roll that decided it: *West to East: Alice (1) →
Bot 2 (5) → Bot 1 (11)*. That is what `state.openingRolls` has been kept for since v0.4.1 and
nothing had yet displayed — and it answers "is the host always at the eastern end" outright. No:
Alice there is the host, rolled lowest, and sits at the western end.
### Supporting changes
`Frame` gained `viewer` and `viewerSeat`. Every private field on it was already scoped to one
player — hand, Office Area, `revenue`, `option`, `movesLeft` — but nothing said which player, so a
page rendering a Frame could draw a railroad without being able to say whose it was. Harmless in
solitaire; the first question at four seats. It also gained `openingRolls`.
Bots are named `Bot 1`, `Bot 2` rather than all being `Bot`: two of them at one table are two
different railroads, and a map labelling both the same cannot say which is which.
The standalone replay gets all of this too — `players`, `actor` and `viewer` are not among the
delta'd keys in `compress`, so they ride whole on every frame and `replay.ts` passes the same
roster the live page does.
---
## 0.5.3 — 2026-08-21 ## 0.5.3 — 2026-08-21
Everything a StartOS administrator needs to see and manage a server full of games, plus the seat Everything a StartOS administrator needs to see and manage a server full of games, plus the seat
+48 -3
View File
@@ -29,6 +29,9 @@ Queued 2026-08-21, from playing the StartOS build:
this is not purely a UI job. this is not purely a UI job.
5. **Decide what the four `optionalRules` are** before either dialog offers them — two are live, 5. **Decide what the four `optionalRules` are** before either dialog offers them — two are live,
two are read by nothing at all. Reasoning in Multiplayer below. two are read by nothing at all. Reasoning in Multiplayer below.
6. **Stop every release destroying every game in progress** — the check is exact equality against
the package version, and most releases do not touch the rules. Reasoning in Multiplayer below;
the recommendation is to replay-and-see rather than to guess from a version number.
--- ---
@@ -402,9 +405,10 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
at a real table; the reasoning worth keeping is that **deny** is the safe default, since a at a real table; the reasoning worth keeping is that **deny** is the safe default, since a
held train costs a Stage and a wrecked one costs 5 Revenue and feeds the collision floor. held train costs a Stage and a wrecked one costs 5 Revenue and feeds the collision floor.
- **~~The opening D12 for the Eastern Division Point (§4.4) decides nothing.~~ Done in - **~~The opening D12 for the Eastern Division Point (§4.4) decides nothing.~~ Done in
v0.4.1** — it orders the whole chain now, west to east by ascending roll. The lobby still owes v0.4.1**, and **displayed in v0.5.4**. It orders the whole chain, west to east by ascending
it a display: `state.openingRolls` is kept so clients can show the rolls forming the chain roll; `openingRolls` is on the `Frame` now and the play page prints the chain under the
rather than only the result (`lobby-and-sessions.md` §4). Division map — *West to East: Alice (1) → Bot 2 (5) → Bot 1 (11)* — so the rolls that formed
it are visible rather than only their result (`lobby-and-sessions.md` §4).
- **Revisit the join secret** (D14). One server-wide secret, passed out of band, gates create - **Revisit the join secret** (D14). One server-wide secret, passed out of band, gates create
and join. Enough for a private box, probably not enough if `stationmaster.<domain>` is and join. Enough for a private box, probably not enough if `stationmaster.<domain>` is
pointed at the open internet for long. Note that one-game-at-a-time per person is expected pointed at the open internet for long. Note that one-game-at-a-time per person is expected
@@ -499,6 +503,47 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
shift it", "never grows the table, whoever asks", "refuses a chair that is not at the shift it", "never grows the table, whoever asks", "refuses a chair that is not at the
table"). table").
- [ ] **EVERY RELEASE DESTROYS EVERY GAME IN PROGRESS, AND MOST RELEASES DO NOT CHANGE THE RULES.**
Raised 2026-08-21 after v0.5.2, v0.5.3 and v0.5.4 each killed the games on the StartOS box in
turn — v0.5.4's changes were *rendering only*, and it still refused two saved games.
**Why it happens, and why the design is right as far as it goes.** A save is a seed plus a
list of intents (D5), so loading one means replaying those intents through the current engine.
A move that was legal under the old rules may be rejected under the new ones, and a
half-replayed game is worse than no game — so `loadGame` refuses on any `engineVersion`
mismatch and `index.ts` logs it and carries on (D7). Nothing is deleted; rolling the version
back makes the games loadable again. That is all correct. The problem is only that the test is
**exact equality against the package version**, which moves for reasons that have nothing to
do with the rules.
**Why it is getting worse rather than better.** It was harmless while Jesse was the only
player. It stops being acceptable the moment other people are seated: their game is destroyed
because somebody shipped a CSS fix. It also interacts badly with the stranded-session bug
fixed in v0.5.5 — the refusal is precisely what stranded a browser on a blank page.
Three ways out, cheapest first:
1. **A separate rules version, bumped by hand.** `RULES_VERSION` in `content.ts`, stamped into
the save instead of `package.json`'s version, and raised only when a change can alter
whether an intent is legal. v0.5.4 would not have touched it and both games would have
survived. Cheapest and the least clever, but it is a judgement call on every release, and
getting it wrong silently corrupts a game rather than refusing it — the failure is worse
than the one it replaces.
2. **A declared compatibility floor.** The save records the version that wrote it; the engine
declares the oldest save it will accept. Loading checks `saved >= floor` rather than
`saved === current`. Same judgement call as (1), but expressed as a range, which makes
"this release breaks saves" an explicit act rather than the default.
3. **Verify rather than assume — replay and see.** Load the save, replay it, and refuse only
if an intent actually rejects. This is the honest test and needs no judgement at all: it
answers the real question ("does this game still replay?") instead of a proxy for it. It
costs a full replay per game on boot, which is ~100 ms per finished game (measured
2026-08-21) and only unfinished games are loaded — so at any realistic table count it is
free. The work is in reporting a partial failure well: the game is intact up to the
rejected intent, and a player would probably rather resume there than lose it entirely.
**(3) is the one worth doing**, and (1)/(2) are what to reach for only if a replay ever
becomes too slow to do on boot. Decide before the next release that changes a rule, not after.
- [ ] **THE FOUR `optionalRules` ARE SETTABLE BY NOTHING, AND TWO OF THEM DO NOTHING.** Split out - [ ] **THE FOUR `optionalRules` ARE SETTABLE BY NOTHING, AND TWO OF THEM DO NOTHING.** Split out
at Jesse's request 2026-08-21, to review on its own rather than as a footnote to the lobby at Jesse's request 2026-08-21, to review on its own rather than as a footnote to the lobby
item below. `GameConfig.optionalRules` (`state.ts:585-588`) carries `reducedVisibility`, item below. `GameConfig.optionalRules` (`state.ts:585-588`) carries `reducedVisibility`,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.5.3", "version": "0.5.5",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
+21
View File
@@ -481,6 +481,27 @@ export function startServer(opts: ServerOptions): void {
// -- The running game (token-authenticated) ------------------------------------------------ // -- The running game (token-authenticated) ------------------------------------------------
/**
* IS THIS TOKEN STILL GOOD FOR ANYTHING?
*
* A browser remembers its session in `localStorage` and re-enters the game on the next load
* without asking, which is what makes reconnection seamless — and what leaves it stranded
* when the game is gone. `EventSource` cannot report a status code and retries a 404
* silently forever, so the client needs somewhere cheap to ask a yes/no question. Two ways a
* game legitimately disappears under a player: an engine-version bump refuses to resume it
* (D7), and an administrator ends it (`DELETE /api/games/<id>`).
*/
if (url.pathname === '/api/session' && req.method === 'GET') {
const ps = sessions.get(url.searchParams.get('token') ?? '');
const live = ps ? games.get(ps.gameId) : undefined;
if (!ps || !live) {
sendJson(res, 404, { error: 'no such game' });
return;
}
sendJson(res, 200, { gameId: ps.gameId, player: ps.player });
return;
}
if (url.pathname === '/api/stream' && req.method === 'GET') { if (url.pathname === '/api/stream' && req.method === 'GET') {
const token = url.searchParams.get('token') ?? ''; const token = url.searchParams.get('token') ?? '';
const ps = sessions.get(token); const ps = sessions.get(token);
+4 -1
View File
@@ -190,7 +190,10 @@ export function startLobby(lobby: Lobby, callerToken: string): StartResult {
} }
// Seat index IS player index — no compaction, because there is nothing to compact past. // Seat index IS player index — no compaction, because there is nothing to compact past.
const taken = lobby.seats as Exclude<LobbySeat, null>[]; const taken = lobby.seats as Exclude<LobbySeat, null>[];
const playerNames = taken.map((s) => (s.kind === 'human' ? s.displayName : 'Bot')); // Bots are numbered rather than all being called "Bot": two of them at one table are two
// different railroads, and a map labelling both the same cannot say which is which.
let botNumber = 0;
const playerNames = taken.map((s) => (s.kind === 'human' ? s.displayName : `Bot ${++botNumber}`));
const botSeats = taken.flatMap((s, i) => (s.kind === 'bot' ? [i as PlayerIndex] : [])); const botSeats = taken.flatMap((s, i) => (s.kind === 'bot' ? [i as PlayerIndex] : []));
return { ok: true, playerNames, botSeats }; return { ok: true, playerNames, botSeats };
} }
+62 -5
View File
@@ -28,7 +28,23 @@ export type BoardTrain = { label: string; consist: string[] };
* The Division as a dispatcher would see it: one continuous line per running track, sections * The Division as a dispatcher would see it: one continuous line per running track, sections
* separated by thin seams, capacity legible because the lines can be counted. * separated by thin seams, capacity legible because the lines can be counted.
*/ */
export function divisionSvg(nodes: DivisionView[]): string { /**
* Who is at the table, so an Office can be labelled with its owner rather than only its tier.
*
* Passed in rather than read off the nodes because a `DivisionView` knows its seat and nothing
* about people — the roster lives on the `Frame`, keyed by player, and `seat` is what joins them.
* Optional so the standalone replay (`replay.ts`, which serialises this function by `toString()`)
* keeps working unchanged.
*/
export type DivisionRoster = {
players: { index: number; seat: number; name: string }[];
/** The player whose move it is, or null in an automatic phase. A PLAYER index, not a seat. */
actor: number | null;
/** The player this map is being drawn for. */
viewer: number;
};
export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | null): string {
/** /**
* THE WHOLE DIVISION, west to east, as one continuous route. * THE WHOLE DIVISION, west to east, as one continuous route.
* *
@@ -97,6 +113,8 @@ export function divisionSvg(nodes: DivisionView[]): string {
tip: string; tip: string;
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */ /** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
seat: number | null; seat: number | null;
/** Set on an Office cell when a roster was supplied: whose district this is. */
owner?: { name: string; isTurn: boolean; isYou: boolean } | null;
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */ /** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
regions: number; regions: number;
w: number; w: number;
@@ -116,12 +134,34 @@ export function divisionSvg(nodes: DivisionView[]): string {
if (n.kind === 'office') { if (n.kind === 'office') {
const cap = n.capacity; const cap = n.capacity;
const ad = n.trains.flat(); const ad = n.trains.flat();
/**
* THE NAME IS THE HEADLINE, the tier is the detail.
*
* "Where does Bob sit?" is the question this map could not answer: an Office was labelled
* with its tier, which every player's Office also has, so four districts read the same. The
* owner's name takes the headline and the tier moves down beside the A/D count, because the
* name is what is being looked for and the tier is what is being referred to once found.
*/
const seatOwner =
roster && n.seat !== null ? (roster.players.find((p) => p.seat === n.seat) ?? null) : null;
const owner = seatOwner
? {
name: seatOwner.name,
isTurn: roster!.actor === seatOwner.index,
isYou: roster!.viewer === seatOwner.index,
}
: null;
for (const rc of n.running ?? []) { for (const rc of n.running ?? []) {
const isOffice = rc.kind === 'office'; const isOffice = rc.kind === 'office';
const adLabel = cap === null ? '' : `A/D ${ad.length}/${cap}`;
push({ push({
kind: 'run', kind: 'run',
label: rc.label, label: isOffice && owner ? owner.name : rc.label,
sub: isOffice ? (cap === null ? '' : `A/D ${ad.length}/${cap}`) : '', owner: isOffice ? owner : null,
// With an owner on the headline the tier would otherwise vanish, so it joins the A/D
// count on the line below.
sub: isOffice ? (owner ? [rc.label, adLabel].filter(Boolean).join(' · ') : adLabel) : '',
/** /**
* A train standing at the Office occupies an A/D track, which is where it is — but it is * A train standing at the Office occupies an A/D track, which is where it is — but it is
* ALSO standing on the Office grid card, so it arrives here in both lists and used to be * ALSO standing on the Office grid card, so it arrives here in both lists and used to be
@@ -131,7 +171,12 @@ export function divisionSvg(nodes: DivisionView[]): string {
? [...rc.trains, ...ad.filter((t) => !rc.trains.some((r) => r.label === t.label))] ? [...rc.trains, ...ad.filter((t) => !rc.trains.some((r) => r.label === t.label))]
: rc.trains, : rc.trains,
cap: isOffice ? cap : null, cap: isOffice ? cap : null,
tip: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`, tip: owner && isOffice
? `${owner.name}'s ${rc.label}` +
(owner.isYou ? ' — this is your railroad' : '') +
// "their move" is wrong when the reader is the one being waited on.
(owner.isTurn ? (owner.isYou ? ' — it is your move' : ' — it is their move') : '')
: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
seat: n.seat ?? null, seat: n.seat ?? null,
// No regions inside a district: a crew moves by Moves there, not by Stages, so it // No regions inside a district: a crew moves by Moves there, not by Stages, so it
// occupies a card outright rather than a part of one. // occupies a card outright rather than a part of one.
@@ -279,7 +324,15 @@ export function divisionSvg(nodes: DivisionView[]): string {
const full = c.cap !== null && c.trains.length >= c.cap; const full = c.cap !== null && c.trains.length >= c.cap;
out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`; out += `<g class="bs-dcell bs-d${c.kind}${full ? ' bs-full' : ''}" data-tip="${esc(c.tip)}">`;
out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`; out += `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${CH}" rx="5"/>`;
out += `<text class="bs-name" x="${c.x + 7}" y="${c.y + 14}">${esc(c.label)}</text>`; /**
* WHOSE IS IT, IS IT THEIR MOVE, AND IS IT MINE — answered by colour and one suffix rather
* than by a legend. Amber is the same "it is happening here" the action panel uses; "(you)"
* is spelled out because a colour alone cannot say which of four railroads is the reader's,
* and that is the first thing anybody wants to know at a table they just sat down at.
*/
const mark = c.owner ? ` bs-owner${c.owner.isTurn ? ' bs-turn' : ''}${c.owner.isYou ? ' bs-you' : ''}` : '';
const suffix = c.owner?.isYou ? ' (you)' : '';
out += `<text class="bs-name${mark}" x="${c.x + 7}" y="${c.y + 14}">${esc(c.label + suffix)}</text>`;
out += rail(c.x + 6, c.y + 32, c.x + c.w - 6); out += rail(c.x + 6, c.y + 32, c.x + c.w - 6);
if (c.sub) out += `<text class="bs-cap" x="${c.x + 7}" y="${c.y + CH - 6}">${esc(c.sub)}</text>`; if (c.sub) out += `<text class="bs-cap" x="${c.x + 7}" y="${c.y + CH - 6}">${esc(c.sub)}</text>`;
@@ -1090,6 +1143,10 @@ export const BOARD_CSS = `
.bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace} .bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace} .bs-coord{fill:#5f6b7a;font:9px ui-monospace,monospace}
.bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace} .bs-name{fill:#e6e9ee;font:600 11px ui-monospace,monospace}
.bs-name.bs-you{fill:#5aa9e6}
/* Their move — wins over .bs-you when both apply, because whose turn it is changes every few
seconds and which railroad is yours never does. */
.bs-name.bs-turn{fill:#f0b64a;font-weight:700}
.bs-cap{fill:#8b94a3;font:10px ui-monospace,monospace} .bs-cap{fill:#8b94a3;font:10px ui-monospace,monospace}
.bs-cap.bs-full{fill:#e0a060;font-weight:600} .bs-cap.bs-full{fill:#e0a060;font-weight:600}
.bs-grade{fill:#e08060;font:10px ui-monospace,monospace} .bs-grade{fill:#e08060;font:10px ui-monospace,monospace}
+4 -1
View File
@@ -513,7 +513,10 @@ function render() {
const CELLS = cellsAt(i), FACS = carry(i, 'facilities'), DIV = carry(i, 'division'); const CELLS = cellsAt(i), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
$('division').innerHTML = divisionSvg(DIV); // players, actor and viewer are not among the delta'd keys (see compress), so they ride whole on
// every frame and the replay names the districts exactly as the live page does. No backticks in
// this comment: it is inside the generated-page template literal, which they would terminate.
$('division').innerHTML = divisionSvg(DIV, { players: f.players, actor: f.actor, viewer: f.viewer });
// The same office renderer the playable app uses, so replay and game draw one board. // The same office renderer the playable app uses, so replay and game draw one board.
$('grid').innerHTML = officeSvg(CELLS, f.runningRow, [], [], f.limits); $('grid').innerHTML = officeSvg(CELLS, f.runningRow, [], [], f.limits);
+23
View File
@@ -370,6 +370,23 @@ export type Frame = {
* player order once §4.4's D12 decided who sits where. * player order once §4.4's D12 decided who sits where.
*/ */
players: { index: number; seat: number; name: string; revenue: number; hand: number }[]; players: { index: number; seat: number; name: string; revenue: number; hand: number }[];
/**
* WHO THIS FRAME WAS BUILT FOR.
*
* Every private thing on a Frame is already scoped to one player — the hand, the Office Area,
* `revenue`, `option`, `movesLeft` — but nothing said which player that was, so a page rendering
* it could show a railroad without being able to say whose it is. Harmless in solitaire, where
* there is only one; the first thing you want to know at a four-player table.
*/
viewer: number;
/** The viewer's position in the west-to-east chain, which is not their player index (§4.4). */
viewerSeat: number;
/**
* §4.4's opening D12 per player, and the roll that chose the Superintendent — kept so a client
* can show the chain being formed rather than only its result (`lobby-and-sessions.md` §4).
* Indexed by player, like `s.players`, not by seat.
*/
openingRolls: { division: number[]; superintendent: number[] };
/** How many cards the VIEWER holds. Other players' counts are in `players`. */ /** How many cards the VIEWER holds. Other players' counts are in `players`. */
handCount: number; handCount: number;
/** /**
@@ -1241,6 +1258,12 @@ export function snapshot(
revenue: p.revenue, revenue: p.revenue,
hand: (s.decks.hands.get(p.index) ?? []).length, hand: (s.decks.hands.get(p.index) ?? []).length,
})), })),
viewer,
viewerSeat,
openingRolls: {
division: [...s.openingRolls.division],
superintendent: [...s.openingRolls.superintendent],
},
handCount: (s.decks.hands.get(viewer) ?? []).length, handCount: (s.decks.hands.get(viewer) ?? []).length,
overHandLimit: overHandLimit:
(s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT), (s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT),
+19 -1
View File
@@ -66,7 +66,7 @@ export function runLobby(onReady: (r: LobbyReady) => void): void {
} }
function renderSeating(lobby: Lobby, you: PlayerIndex, token: string): void { function renderSeating(lobby: Lobby, you: PlayerIndex, token: string): void {
$('lb-gamecode').textContent = `— code ${lobby.gameCode}`; $('lb-gamecode').textContent = lobby.gameCode;
const isHost = lobby.hostToken === token; const isHost = lobby.hostToken === token;
let html = ''; let html = '';
@@ -89,6 +89,24 @@ export function runLobby(onReady: (r: LobbyReady) => void): void {
} }
$('lb-seats').innerHTML = html; $('lb-seats').innerHTML = html;
/**
* The code is the whole invitation, so it has to leave this screen by some route other than
* being read off it and retyped. `navigator.clipboard` is unavailable on an insecure origin
* and can be refused outright, so a failure says the code is there to be selected rather than
* silently doing nothing.
*/
const copyBtn = $<HTMLButtonElement>('lb-copy');
copyBtn.onclick = () => {
const say = (m: string): void => {
$('lb-copied').textContent = m;
setTimeout(() => ($('lb-copied').textContent = ''), 4000);
};
void navigator.clipboard
?.writeText(lobby.gameCode)
.then(() => say('Copied.'))
.catch(() => say('Could not copy — select the code above instead.'));
};
for (const btn of Array.from($('lb-seats').querySelectorAll<HTMLButtonElement>('.lb-bot-add'))) { for (const btn of Array.from($('lb-seats').querySelectorAll<HTMLButtonElement>('.lb-bot-add'))) {
btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: true }); btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: true });
} }
+66 -2
View File
@@ -333,7 +333,11 @@ function showScreen(which: 'lobby' | 'gameui'): void {
function beginRemote(ready: LobbyReady): void { function beginRemote(ready: LobbyReady): void {
localStorage.setItem(REMOTE_KEY, JSON.stringify(ready)); localStorage.setItem(REMOTE_KEY, JSON.stringify(ready));
showScreen('gameui'); showScreen('gameui');
session = createRemoteSession(ready.token, ready.seat); // Nothing can be drawn until the first push arrives, and a page showing nothing at all is
// indistinguishable from a page that is broken — which is exactly what a dead session used to
// look like, forever.
$('presence').textContent = '… connecting to the game';
session = createRemoteSession(ready.token, ready.seat, abandonRemote);
applyCapabilities(); applyCapabilities();
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first // A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
// real Frame only exists once the SSE connection's first push arrives, so the first render waits // real Frame only exists once the SSE connection's first push arrives, so the first render waits
@@ -342,6 +346,31 @@ function beginRemote(ready: LobbyReady): void {
session.subscribe(render); session.subscribe(render);
} }
/**
* The game this browser remembered is gone, so stop waiting for it and go somewhere useful.
*
* Two things legitimately destroy a game under a seated player, and both are by design: an
* engine-version bump refuses to resume it (D7 — a move legal under the old rules may not be under
* the new ones), and an administrator ends it. Neither used to be survivable here. The remembered
* token sent `start()` straight past the lobby into a game that no longer existed, `EventSource`
* retried the 404 in silence, and the player sat on a blank page with no controls and no way back
* short of clearing site data.
*
* Forgetting the token is what makes the next load land in the lobby instead of repeating it.
*/
function abandonRemote(): void {
localStorage.removeItem(REMOTE_KEY);
showScreen('lobby');
$('presence').textContent = '';
runLobby(beginRemote);
const note = document.getElementById('lb-create-err');
if (note) {
note.textContent =
'That game is no longer on this server — it was either ended by whoever runs it, or the ' +
'service was updated, which does not carry games in progress across. Create or join a new one.';
}
}
/** /**
* NO `?seat=` SHORTCUT ANY MORE. A remote game is reached by creating or joining one through * NO `?seat=` SHORTCUT ANY MORE. A remote game is reached by creating or joining one through
* `#lobby` (`lobby.ts`), which is what hands out the token `beginRemote` needs — hand-editing a URL * `#lobby` (`lobby.ts`), which is what hands out the token `beginRemote` needs — hand-editing a URL
@@ -352,6 +381,9 @@ function beginRemote(ready: LobbyReady): void {
function start(): void { function start(): void {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
// Entered without checking it still exists — deliberately. Verifying up front would mean an
// await before anything renders on the common path, where the game IS still there; instead the
// session reports a dead game through `abandonRemote`, which lands in the lobby.
const remembered = loadRemote(); const remembered = loadRemote();
if (remembered) { if (remembered) {
beginRemote(remembered); beginRemote(remembered);
@@ -408,6 +440,33 @@ function applyCapabilities(): void {
hide('multiplayer', c.newGame); hide('multiplayer', c.newGame);
} }
/**
* The west-to-east chain in words, with the D12 that decided it (§4.4).
*
* The map shows where everyone ended up; this says WHY, which is the half `state.openingRolls` was
* kept for. It is also the answer to "am I always at the eastern end" — no, the roll decides, and
* here is the roll.
*/
function renderSeatingChain(f: Frame): void {
const el = document.getElementById('seating-chain');
if (!el) return;
if (f.players.length < 2) {
el.textContent = '';
return;
}
const bySeat = [...f.players].sort((a, b) => a.seat - b.seat);
const chain = bySeat
.map((p) => {
const roll = f.openingRolls.division[p.index];
const marks = [p.index === f.viewer ? 'you' : '', p.index === f.actor ? 'now' : '']
.filter(Boolean)
.join(', ');
return `${p.name}${roll === undefined ? '' : ` (${roll})`}${marks ? ` [${marks}]` : ''}`;
})
.join(' → ');
el.textContent = `West to East: ${chain}. Order set by the opening D12 — highest roll takes the eastern end.`;
}
/** /**
* `lobby-and-sessions.md` §5 — names every currently-DISCONNECTED other seat, so a stalled table * `lobby-and-sessions.md` §5 — names every currently-DISCONNECTED other seat, so a stalled table
* has a reason on screen instead of silence. Always empty for a `LocalSession` (`presence()` never * has a reason on screen instead of silence. Always empty for a `LocalSession` (`presence()` never
@@ -464,7 +523,12 @@ function render(): void {
renderHouseRules(f.houseRules); renderHouseRules(f.houseRules);
// -- division // -- division
$('division').innerHTML = divisionSvg(f.division); $('division').innerHTML = divisionSvg(f.division, {
players: f.players,
actor: f.actor,
viewer: f.viewer,
});
renderSeatingChain(f);
applyZoom($('division')); applyZoom($('division'));
// -- board. Both renderers are shared with the replay so the two can never draw different // -- board. Both renderers are shared with the replay so the two can never draw different
+26 -3
View File
@@ -35,6 +35,10 @@ header button:disabled{opacity:.45;cursor:not-allowed;border-color:#2c333d}
header button:disabled:hover{border-color:#2c333d} header button:disabled:hover{border-color:#2c333d}
.zoom{display:inline-flex;align-items:center;gap:4px} .zoom{display:inline-flex;align-items:center;gap:4px}
.zoom button{padding:3px 9px;line-height:1} .zoom button{padding:3px 9px;line-height:1}
.lb-invite{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:0 0 10px;
background:#1e242c;border:1px solid var(--line);border-radius:7px;padding:10px 12px}
.lb-invite-label{font-size:11px;color:var(--dim)}
.lb-invite-code{font-size:22px;font-weight:700;letter-spacing:.08em;color:#f2e6cf}
.zoom #zoomlabel{font-size:11px;color:var(--dim);min-width:32px;text-align:center;display:inline-block} .zoom #zoomlabel{font-size:11px;color:var(--dim);min-width:32px;text-align:center;display:inline-block}
.build{margin-left:auto;font-size:10px;opacity:.55;white-space:nowrap} .build{margin-left:auto;font-size:10px;opacity:.55;white-space:nowrap}
.home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a} .home{color:inherit;text-decoration:none;border-bottom:1px dotted #5f6b7a}
@@ -165,6 +169,12 @@ button.act.crew.on{border-color:var(--now);background:rgba(185,140,240,.18);colo
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px; button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;
padding:5px 9px;margin:2px 3px 2px 0;cursor:pointer;font:inherit;font-size:12px;text-align:left} padding:5px 9px;margin:2px 3px 2px 0;cursor:pointer;font:inherit;font-size:12px;text-align:left}
button:hover{background:#39424e;border-color:#4d6fa8} button:hover{background:#39424e;border-color:#4d6fa8}
/* GENERIC, and it was not. `header button:disabled` and `#actions button:disabled` were the only
disabled styles on the page, so a disabled button anywhere else — #lb-start being the one that
mattered — kept its normal face AND still lit up under the cursor from the rule above. It was
advertising a click it would refuse. */
button:disabled{opacity:.45;cursor:not-allowed}
button:disabled:hover{background:#2a3038;border-color:var(--line)}
#actions button{background:#2b3444;border:2px solid #c8912f;box-shadow:0 0 0 1px rgba(200,145,47,.18); #actions button{background:#2b3444;border:2px solid #c8912f;box-shadow:0 0 0 1px rgba(200,145,47,.18);
color:#f2e6cf;font-weight:600} color:#f2e6cf;font-weight:600}
#actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)} #actions button:hover{background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)}
@@ -261,8 +271,20 @@ ul.blocked li{padding:2px 0}
<!-- Shown once created or joined, in place of the choice above, until the host starts the game. --> <!-- Shown once created or joined, in place of the choice above, until the host starts the game. -->
<section id="lb-seating-section" hidden> <section id="lb-seating-section" hidden>
<h2>Seating <span class="dim" id="lb-gamecode"></span></h2> <h2>Seating</h2>
<p class="ng-note">West to East, in the order everyone joined — this order decides the Superintendent rotation and which Office is adjacent to which. The host may fill an empty seat with a bot, or start once every seat is either a player or a bot.</p> <div class="lb-invite">
<div>
<div class="lb-invite-label">Send this code to your players</div>
<div class="lb-invite-code" id="lb-gamecode"></div>
</div>
<button id="lb-copy" class="ghost">Copy</button>
<span class="ng-note" id="lb-copied"></span>
</div>
<p class="ng-note">They enter it under <b>Join a game</b>, along with the same join secret you used.</p>
<p class="ng-note">The host may fill an empty seat with a bot, and starts the game once every
seat is either a player or a bot. <b>These chairs are not the running order</b> — who sits
where along the Division is decided by a D12 roll when the game starts (§4.4), and the map
shows the result.</p>
<div id="lb-seats"></div> <div id="lb-seats"></div>
<button id="lb-start" disabled>Start game</button> <button id="lb-start" disabled>Start game</button>
<p class="dim" id="lb-start-note"></p> <p class="dim" id="lb-start-note"></p>
@@ -315,7 +337,8 @@ ul.blocked li{padding:2px 0}
<main> <main>
<div> <div>
<section><h2>The Division — west to east</h2><div id="division"></div></section> <section><h2>The Division — west to east</h2><div id="division"></div>
<p class="ng-note" id="seating-chain"></p></section>
<section id="district"> <section id="district">
<h2>Your Office Area <h2>Your Office Area
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span> <span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
+34 -1
View File
@@ -226,7 +226,16 @@ type Push = {
* not rendering until `subscribe`'s callback fires at least once for a session whose `capabilities` * not rendering until `subscribe`'s callback fires at least once for a session whose `capabilities`
* are all `false` (a `LocalSession` always has data the instant it is constructed; this does not). * are all `false` (a `LocalSession` always has data the instant it is constructed; this does not).
*/ */
export function createRemoteSession(token: string, seat: PlayerIndex): Session { export function createRemoteSession(
token: string,
seat: PlayerIndex,
/**
* Called once when this session's game is established to be gone for good, so the page can stop
* waiting for it. Without this the only symptom is a blank screen: `EventSource` retries a 404
* forever and reports nothing, and `frame` never becomes non-null.
*/
onGone?: () => void,
): Session {
let frame: Frame | null = null; let frame: Frame | null = null;
let menu: Menu | null = null; let menu: Menu | null = null;
let lines: { text: string; tone: string }[] = []; let lines: { text: string; tone: string }[] = [];
@@ -239,6 +248,30 @@ export function createRemoteSession(token: string, seat: PlayerIndex): Session {
const qs = `token=${encodeURIComponent(token)}`; const qs = `token=${encodeURIComponent(token)}`;
const source = new EventSource(`/api/stream?${qs}`); const source = new EventSource(`/api/stream?${qs}`);
/**
* A DROPPED CONNECTION AND A DEAD GAME LOOK IDENTICAL HERE, so ask before giving up.
*
* `EventSource` fires `error` for both a transient blip — which it recovers from by itself, and
* which is the expected shape of a game that sits idle for minutes (multiplayer.md §9) — and a
* 404 it will nonetheless retry forever. It exposes no status code either way. `/api/session` is
* the cheap question that separates them: only a definite 404 closes the stream and reports the
* game gone, so a flaky network still self-heals.
*/
let reportedGone = false;
source.onerror = () => {
if (reportedGone) return;
void fetch(`/api/session?${qs}`)
.then((r) => {
if (r.status !== 404 || reportedGone) return;
reportedGone = true;
source.close();
onGone?.();
})
.catch(() => {
// The probe itself failed, so this says nothing about the game — leave the retry running.
});
};
source.onmessage = (ev: MessageEvent<string>) => { source.onmessage = (ev: MessageEvent<string>) => {
const push = JSON.parse(ev.data) as Push; const push = JSON.parse(ev.data) as Push;
// A presence-only push (no `frame`) carries `menu: null` too, but that is not news about this // A presence-only push (no `frame`) carries `menu: null` too, but that is not news about this
+82
View File
@@ -18,6 +18,7 @@ import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts'
import { coordKey, playerAtSeat, playerLeftOf, seatOf, subdivisions } from '../src/engine/state.ts'; import { coordKey, playerAtSeat, playerLeftOf, seatOf, subdivisions } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts'; import { developerBot, playGame } from '../src/sim/bot.ts';
import { snapshot } from '../src/sim/view.ts'; import { snapshot } from '../src/sim/view.ts';
import { divisionSvg } from '../src/sim/board-svg.ts';
import { impediments } from '../src/sim/narrate.ts'; import { impediments } from '../src/sim/narrate.ts';
import { readFileSync, readdirSync } from 'node:fs'; import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -712,3 +713,84 @@ describe('actionMenu is seat-safe (Phase 2 prep)', () => {
} }
}); });
}); });
describe('the map says whose railroad is whose', () => {
it('the Frame names its own viewer, which nothing on it did before', () => {
// Every private field is already scoped to one player — hand, Office Area, revenue, option —
// but a page rendering that could not say WHICH player, so it could not tell you which of four
// railroads was yours.
const s = game(4);
for (const viewer of [0, 1, 2, 3] as PlayerIndex[]) {
const f = snapshot(s, [], null, null, null, false, viewer);
assert.equal(f.viewer, viewer);
assert.equal(f.viewerSeat, seatOf(s, viewer), 'viewerSeat must be the seat, not the player index');
}
});
it('carries the opening D12 that decided the west-to-east chain', () => {
const s = game(4);
const f = snapshot(s, [], null, null, null, false, 0 as PlayerIndex);
assert.equal(f.openingRolls.division.length, 4, 'one division roll per player');
assert.equal(f.openingRolls.superintendent.length, 4);
// The rule the rolls implement: ascending by roll, west to east — so sorting the players by
// their roll must reproduce the seating exactly (§4.4).
const bySeat = [...f.players].sort((a, b) => a.seat - b.seat).map((p) => p.index);
const byRoll = [...f.players]
.map((p) => p.index)
.sort((a, b) => f.openingRolls.division[a]! - f.openingRolls.division[b]! || b - a);
assert.deepEqual(bySeat, byRoll, 'seating does not follow the opening rolls');
});
it('is not always the host at the eastern end — the roll decides', () => {
// The question this answers: player 0 is the lobby host, and the eastern end is the LAST seat.
// If the two were the same thing, every seed would put player 0 there.
const easternPlayer = (seed: number): number => {
const s = game(4, seed);
const f = snapshot(s, [], null, null, null, false, 0 as PlayerIndex);
return [...f.players].sort((a, b) => b.seat - a.seat)[0]!.index;
};
const seen = new Set([101, 202, 303, 404, 505, 606].map(easternPlayer));
assert.ok(seen.size > 1, `the eastern end was always player ${[...seen][0]} across six seeds`);
});
it('labels each Office with its owner, marking whose move it is and which one is yours', () => {
const s = game(3);
const viewer = 1 as PlayerIndex;
const f = snapshot(s, [], null, null, null, false, viewer);
const svg = divisionSvg(f.division, { players: f.players, actor: f.actor, viewer: f.viewer });
const owners = [...svg.matchAll(/<text class="bs-name([^"]*bs-owner[^"]*)"[^>]*>([^<]*)<\/text>/g)].map(
(m) => ({ classes: m[1]!, text: m[2]! }),
);
assert.equal(owners.length, 3, 'expected one owner-labelled Office per player');
// Every player is named somewhere, in seat order.
const bySeat = [...f.players].sort((a, b) => a.seat - b.seat);
assert.deepEqual(
owners.map((o) => o.text.replace(' (you)', '')),
bySeat.map((p) => p.name),
);
const you = owners.find((o) => o.classes.includes('bs-you'));
assert.ok(you, 'the viewer’s own Office is not marked');
assert.ok(you!.text.endsWith('(you)'), 'colour alone cannot say which railroad is the reader’s');
assert.equal(
you!.text.replace(' (you)', ''),
f.players.find((p) => p.index === viewer)!.name,
'the (you) mark is on the wrong Office',
);
const turn = owners.filter((o) => o.classes.includes('bs-turn'));
assert.equal(turn.length, f.actor === null ? 0 : 1, 'exactly one Office is the current actor’s');
if (f.actor !== null) {
assert.equal(turn[0]!.text.replace(' (you)', ''), f.players.find((p) => p.index === f.actor)!.name);
}
});
it('draws no owner marks at all when given no roster, so the replay still renders', () => {
const s = game(3);
const f = snapshot(s, [], null, null, null, false, 0 as PlayerIndex);
assert.equal(divisionSvg(f.division).includes('bs-owner'), false);
});
});
+15 -3
View File
@@ -178,11 +178,23 @@ describe('starting', () => {
assert.deepEqual(startLobby(lobby, lobby.hostToken), { ok: false, code: 'BAD_PLAYER_COUNT' }); 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', () => { 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; let lobby = createLobby(competitive, 'Alice', 'RAIL-0011', 2).lobby;
lobby = setBotSeat(lobby, 1, true); lobby = setBotSeat(lobby, 1, true);
const r = startLobby(lobby, lobby.hostToken); const r = startLobby(lobby, lobby.hostToken);
assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bot'], botSeats: [1] }); 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', () => { it('starts a solitaire lobby of exactly 1', () => {
@@ -201,7 +213,7 @@ describe('starting', () => {
if (!bob.ok) return; if (!bob.ok) return;
lobby = setBotSeat(setBotSeat(bob.lobby, 2, true), 3, true); lobby = setBotSeat(setBotSeat(bob.lobby, 2, true), 3, true);
const r = startLobby(lobby, lobby.hostToken); const r = startLobby(lobby, lobby.hostToken);
assert.deepEqual(r, { ok: true, playerNames: ['Alice', 'Bob', 'Bot', 'Bot'], botSeats: [2, 3] }); 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"); assert.equal(bob.session.player, 1, "Bob's stored player index still names his chair");
}); });
}); });