Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51710498f5 |
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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)}` },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user