v0.5.6 — seats counted from 1, a name you can read, and a line that stops repeating

Three things off the first proper look at a live table.

The lobby listed chairs as Seat 0 to Seat 3. Zero-based is right inside —
it indexes seating, the seats array and every route, and none of that
changes — but nobody sitting at a table calls their chair "seat 0". There
were four of these rather than one: the lobby list, the topline's Seat N
for a remote session, the presence banner's fallback name, and the admin
summary's. All go through a single seatLabel now, and a test fails the
build if any "Seat ${...}" interpolates a raw seat again, since the
conversion has to happen in exactly one place or the two conventions drift
apart. Verified by mutation — putting the raw seat back fails the suite.

seatLabel lives in sim/view.ts, not web/game.ts. Putting it in game.ts was
the first attempt and test/session.test.ts caught it: the page may not
import values from that module, because they are the local engine by
another name and importing one reopens the Phase 1 boundary. The test was
right and the placement was wrong.

.bs-name.bs-turn carried font-weight:700 over a base of 600. At 11px a
monospace face has to be synthesised the rest of the way and the extra ink
lands as blur, so the one name you most need to read was the one you could
not. The bump is gone; amber against #e6e9ee was always doing the work, and
blue "(you)" and amber "their move" stay clearly distinct without it.

The west-to-east chain under the Division map now shows only during Day 1
Stage 1. It answers who is where and why, which is a question you have once
— at the start, when the chain has just been rolled and the names are new.
By Stage 2 the map has been answering it for a while and the line is
something to read past.

675 tests pass (673 + 2).
This commit is contained in:
Jesse
2026-08-21 20:53:17 -04:00
parent bfd2708ecc
commit 51710498f5
8 changed files with 106 additions and 10 deletions
+38
View File
@@ -19,6 +19,44 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
--- ---
## 0.5.6 — 2026-08-21
Three things off the first proper look at a live table.
### Seats are counted from 1
The lobby listed chairs as Seat 0 to Seat 3. Zero-based is right *inside* — it indexes `seating`,
the seats array and every route, and none of that changes — but nobody sitting down at a table
calls their chair "seat 0". The displayed number is now the one a player would say out loud.
There were four of these, not one: the lobby list, the topline's `Seat N` for a remote session, the
presence banner's fallback name, and the admin summary's. All go through a single `seatLabel`, and
a test fails the build if any `Seat ${…}` interpolates a raw seat again — the conversion has to
happen at exactly one place or the two conventions drift. (The StartOS package's **Games in
Progress** action had the same leak and is fixed alongside.)
`seatLabel` lives in `view.ts` rather than `web/game.ts`, because the page may not import values
from that module — they are the local engine by another name, and `test/session.test.ts` fails the
build for it. Putting it there was the first attempt; the test was right and the placement was
wrong.
### The current player's name was unreadable
`.bs-name.bs-turn` carried `font-weight:700` over a base of 600. At 11px a monospace face has to be
synthesised the rest of the way, and the extra ink lands as blur rather than as weight — so the one
name you most need to read was the one you could not. The weight bump is gone; amber against
`#e6e9ee` was always doing the work, and blue "(you)" and amber "their move" stay clearly distinct
without it.
### The seating chain says its piece once
The west-to-east line under the Division map explains who is where and why, which is a question you
have once — at the start, when the chain has just been rolled and the names are new. It now shows
only during Day 1 Stage 1. By Stage 2 the map itself has been answering it for a while, and a
permanent line restating it is a permanent line to read past.
---
## 0.5.5 — 2026-08-21 ## 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 One bug, found by updating to v0.5.4 and clicking Multiplayer: the page went straight into a game
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.5.5", "version": "0.5.6",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
+2 -2
View File
@@ -26,7 +26,7 @@ import { actionMenu, currentActor, fromMultiplayerSave, newMultiplayerGame, subm
import type { Game, Menu } from '../web/game.ts'; import type { Game, Menu } from '../web/game.ts';
import { deltaFrame } from '../sim/frame-delta.ts'; import { deltaFrame } from '../sim/frame-delta.ts';
import type { FrameDelta } from '../sim/frame-delta.ts'; import type { FrameDelta } from '../sim/frame-delta.ts';
import { snapshot } from '../sim/view.ts'; import { snapshot, seatLabel } from '../sim/view.ts';
import type { Frame } from '../sim/view.ts'; import type { Frame } from '../sim/view.ts';
import { developerBot } from '../sim/bot.ts'; import { developerBot } from '../sim/bot.ts';
@@ -298,7 +298,7 @@ function buildSession(
day: game.state.clock.day, day: game.state.clock.day,
stage: game.state.clock.stage, stage: game.state.clock.stage,
phase: game.state.clock.phase, phase: game.state.clock.phase,
waitingOn: actor === null ? null : { seat: actor, name: playerNames[actor] ?? `Seat ${actor}` }, waitingOn: actor === null ? null : { seat: actor, name: playerNames[actor] ?? `Seat ${seatLabel(actor)}` },
}; };
}, },
}; };
+7 -2
View File
@@ -1145,8 +1145,13 @@ export const BOARD_CSS = `
.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} .bs-name.bs-you{fill:#5aa9e6}
/* Their move — wins over .bs-you when both apply, because whose turn it is changes every few /* 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. */ seconds and which railroad is yours never does.
.bs-name.bs-turn{fill:#f0b64a;font-weight:700}
NO WEIGHT BUMP. This was 700 and the name came out fuzzy to the point of being unreadable: the
base is already 600, so at 11px a monospace face has to be synthesised the rest of the way, and
the extra ink lands as blur rather than as weight. Amber against #e6e9ee is the distinction; it
does not need help. */
.bs-name.bs-turn{fill:#f0b64a}
.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}
+16
View File
@@ -275,6 +275,22 @@ export type RunningCardView = {
trains: TrainChip[]; trains: TrainChip[];
}; };
/**
* A seat as a PERSON counts them, from 1.
*
* Seats are zero-based everywhere inside — `PlayerIndex`, `seating`, the seats array, every route
* — and that must not change, since it is what indexes into all of them. But nobody sitting down
* at a table calls their chair "seat 0", so the number on screen is the one they would say out
* loud. Every user-facing seat goes through here, so the two conventions cannot drift apart.
*
* It lives here rather than in `web/game.ts` because the page may not import values from that
* module — they are the local engine by another name, and `test/session.test.ts` fails the build
* for it. This is presentation, which is what `view.ts` is for.
*/
export function seatLabel(seat: number): number {
return seat + 1;
}
export type DivisionView = { export type DivisionView = {
kind: string; kind: string;
label: string; label: string;
+2 -1
View File
@@ -13,6 +13,7 @@
import type { GameConfig, PlayerIndex } from '../engine/state.ts'; import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import { defaultMultiplayerConfig } from './game.ts'; import { defaultMultiplayerConfig } from './game.ts';
import { seatLabel } from '../sim/view.ts';
export type LobbyReady = { token: string; gameId: string; seat: PlayerIndex }; export type LobbyReady = { token: string; gameId: string; seat: PlayerIndex };
@@ -85,7 +86,7 @@ export function runLobby(onReady: (r: LobbyReady) => void): void {
if (occupant === null) action = `<button class="lb-bot-add" data-seat="${seat}">+ bot</button>`; if (occupant === null) action = `<button class="lb-bot-add" data-seat="${seat}">+ bot</button>`;
else if (occupant.kind === 'bot') action = `<button class="lb-bot-remove" data-seat="${seat}">remove bot</button>`; else if (occupant.kind === 'bot') action = `<button class="lb-bot-remove" data-seat="${seat}">remove bot</button>`;
} }
html += `<div class="lb-seat"><span class="dim">Seat ${seat}</span><span class="who">${who}</span>${action}</div>`; html += `<div class="lb-seat"><span class="dim">Seat ${seatLabel(seat)}</span><span class="who">${who}</span>${action}</div>`;
} }
$('lb-seats').innerHTML = html; $('lb-seats').innerHTML = html;
+12 -3
View File
@@ -8,6 +8,7 @@
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts'; import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts'; import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
import type { Frame } from '../sim/view.ts'; import type { Frame } from '../sim/view.ts';
import { seatLabel } from '../sim/view.ts';
import type { Menu, Save } from './game.ts'; import type { Menu, Save } from './game.ts';
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts'; import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts'; import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
@@ -450,7 +451,15 @@ function applyCapabilities(): void {
function renderSeatingChain(f: Frame): void { function renderSeatingChain(f: Frame): void {
const el = document.getElementById('seating-chain'); const el = document.getElementById('seating-chain');
if (!el) return; if (!el) return;
if (f.players.length < 2) { /**
* ONLY WHILE THE GAME IS STILL OPENING.
*
* This answers "who is where, and why" — which is a question you have once, at the start, when
* the chain has just been rolled and the names are new. By Day 1 Stage 2 the map itself has been
* answering it for a while, and a permanent line restating it is a permanent line to read past.
*/
const opening = f.day === 1 && f.stage === 1;
if (f.players.length < 2 || !opening) {
el.textContent = ''; el.textContent = '';
return; return;
} }
@@ -477,7 +486,7 @@ function renderPresence(f: Frame): void {
const away = session const away = session
.presence() .presence()
.filter((p) => !p.connected) .filter((p) => !p.connected)
.map((p) => f.players.find((pl) => pl.index === p.seat)?.name ?? `Seat ${p.seat}`); .map((p) => f.players.find((pl) => pl.index === p.seat)?.name ?? `Seat ${seatLabel(p.seat)}`);
$('presence').textContent = away.length === 0 ? '' : `⚠ waiting on ${away.join(', ')} — disconnected`; $('presence').textContent = away.length === 0 ? '' : `⚠ waiting on ${away.join(', ')} — disconnected`;
} }
@@ -519,7 +528,7 @@ function render(): void {
obj.className = 'pace'; obj.className = 'pace';
// The seed is never sent to a remote client at all (it would leak every future shuffle and roll, // The seed is never sent to a remote client at all (it would leak every future shuffle and roll,
// `multiplayer.md` §7) — `RemoteSession` has no `.seed()` because there is nothing to return. // `multiplayer.md` §7) — `RemoteSession` has no `.seed()` because there is nothing to return.
$('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${session.seat()}`; $('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${seatLabel(session.seat())}`;
renderHouseRules(f.houseRules); renderHouseRules(f.houseRules);
// -- division // -- division
+28 -1
View File
@@ -14,11 +14,12 @@
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { actionGroups, currentActor, handPlayable, newGame, overHandLimit, submit, toSave, view } from '../src/web/game.ts'; import { actionGroups, currentActor, handPlayable, newGame, overHandLimit, submit, toSave, view } from '../src/web/game.ts';
import { createLocalSession } from '../src/web/session.ts'; import { createLocalSession } from '../src/web/session.ts';
import { seatLabel } from '../src/sim/view.ts';
/** /**
* Drive a session by always taking the first offered action. * Drive a session by always taking the first offered action.
@@ -218,6 +219,32 @@ describe('the page stays on the near side of the boundary', () => {
}); });
}); });
describe('seats are counted from 1 wherever a person reads them', () => {
it('seatLabel shifts the zero-based index the whole engine uses', () => {
assert.deepEqual([0, 1, 2, 3].map(seatLabel), [1, 2, 3, 4]);
});
it('no user-facing "Seat N" bypasses it', () => {
// The internal convention is zero-based and must stay that way — it indexes `seating`, the
// seats array and every route. The DISPLAYED number is the one a player would say out loud, so
// the two have to be converted at exactly one place; anything interpolating a raw seat into a
// "Seat …" string has quietly reintroduced "Seat 0".
const roots = ['src/web', 'src/sim', 'src/server'];
const offenders: string[] = [];
for (const dir of roots) {
const base = join(import.meta.dirname, '..', dir);
for (const name of readdirSync(base, { recursive: true, encoding: 'utf8' })) {
if (!name.endsWith('.ts')) continue;
const text = readFileSync(join(base, name), 'utf8');
for (const m of text.matchAll(/`[^`]*Seat \$\{([^}]*)\}/g)) {
if (!m[1]!.includes('seatLabel')) offenders.push(`${dir}/${name}: ${m[0]!.slice(0, 60)}`);
}
}
}
assert.deepEqual(offenders, [], 'a seat is shown to a player without going through seatLabel');
});
});
describe('capabilities say what only a local session can do', () => { describe('capabilities say what only a local session can do', () => {
it('offers undo, a local save and a new deal', () => { it('offers undo, a local save and a new deal', () => {
// The page hides these rather than calling them and failing. A server can offer none of them: it // The page hides these rather than calling them and failing. A server can offer none of them: it