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