From 51710498f54c55a32380badba5157b0a0dfb65c3 Mon Sep 17 00:00:00 2001 From: Jesse Date: Fri, 21 Aug 2026 20:53:17 -0400 Subject: [PATCH] =?UTF-8?q?v0.5.6=20=E2=80=94=20seats=20counted=20from=201?= =?UTF-8?q?,=20a=20name=20you=20can=20read,=20and=20a=20line=20that=20stop?= =?UTF-8?q?s=20repeating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CHANGELOG.md | 38 ++++++++++++++++++++++++++++++++++++++ package.json | 2 +- src/server/session.ts | 4 ++-- src/sim/board-svg.ts | 9 +++++++-- src/sim/view.ts | 16 ++++++++++++++++ src/web/lobby.ts | 3 ++- src/web/main.ts | 15 ++++++++++++--- test/session.test.ts | 29 ++++++++++++++++++++++++++++- 8 files changed, 106 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2d8ef2..15af5df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,44 @@ page as `v0.1.0 · · `, 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 One bug, found by updating to v0.5.4 and clicking Multiplayer: the page went straight into a game diff --git a/package.json b/package.json index c6aa1a2..880eb85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.5.5", + "version": "0.5.6", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/server/session.ts b/src/server/session.ts index 922c496..33399bc 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -26,7 +26,7 @@ import { actionMenu, currentActor, fromMultiplayerSave, newMultiplayerGame, subm import type { Game, Menu } from '../web/game.ts'; import { deltaFrame } 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 { developerBot } from '../sim/bot.ts'; @@ -298,7 +298,7 @@ function buildSession( day: game.state.clock.day, stage: game.state.clock.stage, 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)}` }, }; }, }; diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts index 8d31cc9..5c29f79 100644 --- a/src/sim/board-svg.ts +++ b/src/sim/board-svg.ts @@ -1145,8 +1145,13 @@ export const BOARD_CSS = ` .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} + seconds and which railroad is yours never does. + + 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.bs-full{fill:#e0a060;font-weight:600} .bs-grade{fill:#e08060;font:10px ui-monospace,monospace} diff --git a/src/sim/view.ts b/src/sim/view.ts index 27c457b..3a5172f 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -275,6 +275,22 @@ export type RunningCardView = { 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 = { kind: string; label: string; diff --git a/src/web/lobby.ts b/src/web/lobby.ts index b5f77c4..5610830 100644 --- a/src/web/lobby.ts +++ b/src/web/lobby.ts @@ -13,6 +13,7 @@ import type { GameConfig, PlayerIndex } from '../engine/state.ts'; import { defaultMultiplayerConfig } from './game.ts'; +import { seatLabel } from '../sim/view.ts'; 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 = ``; else if (occupant.kind === 'bot') action = ``; } - html += `
Seat ${seat}${who}${action}
`; + html += `
Seat ${seatLabel(seat)}${who}${action}
`; } $('lb-seats').innerHTML = html; diff --git a/src/web/main.ts b/src/web/main.ts index e528389..00f5645 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -8,6 +8,7 @@ import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts'; import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts'; import type { Frame } from '../sim/view.ts'; +import { seatLabel } from '../sim/view.ts'; import type { Menu, Save } from './game.ts'; import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts'; import { TOOLTIP_CSS, installTooltips } from './tooltip.ts'; @@ -450,7 +451,15 @@ function applyCapabilities(): void { function renderSeatingChain(f: Frame): void { const el = document.getElementById('seating-chain'); 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 = ''; return; } @@ -477,7 +486,7 @@ function renderPresence(f: Frame): void { const away = session .presence() .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`; } @@ -519,7 +528,7 @@ function render(): void { obj.className = 'pace'; // 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. - $('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${session.seat()}`; + $('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${seatLabel(session.seat())}`; renderHouseRules(f.houseRules); // -- division diff --git a/test/session.test.ts b/test/session.test.ts index f812437..dfa3b0c 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -14,11 +14,12 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { actionGroups, currentActor, handPlayable, newGame, overHandLimit, submit, toSave, view } from '../src/web/game.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. @@ -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', () => { 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