diff --git a/CHANGELOG.md b/CHANGELOG.md index d15e931..8835788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,66 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 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 Everything a StartOS administrator needs to see and manage a server full of games, plus the seat diff --git a/TODO.md b/TODO.md index 02887d1..9e78054 100644 --- a/TODO.md +++ b/TODO.md @@ -402,9 +402,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 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 - v0.4.1** — it orders the whole chain now, west to east by ascending roll. The lobby still owes - it a display: `state.openingRolls` is kept so clients can show the rolls forming the chain - rather than only the result (`lobby-and-sessions.md` §4). + v0.4.1**, and **displayed in v0.5.4**. It orders the whole chain, west to east by ascending + roll; `openingRolls` is on the `Frame` now and the play page prints the chain under the + 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 and join. Enough for a private box, probably not enough if `stationmaster.` is pointed at the open internet for long. Note that one-game-at-a-time per person is expected diff --git a/package.json b/package.json index 3134841..1134c0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.5.3", + "version": "0.5.4", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/server/lobby.ts b/src/server/lobby.ts index 5ca1c45..6df65f6 100644 --- a/src/server/lobby.ts +++ b/src/server/lobby.ts @@ -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. const taken = lobby.seats as Exclude[]; - 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] : [])); return { ok: true, playerNames, botSeats }; } diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts index 5a9b5df..8d31cc9 100644 --- a/src/sim/board-svg.ts +++ b/src/sim/board-svg.ts @@ -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 * 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. * @@ -97,6 +113,8 @@ export function divisionSvg(nodes: DivisionView[]): string { tip: string; /** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */ 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. */ regions: number; w: number; @@ -116,12 +134,34 @@ export function divisionSvg(nodes: DivisionView[]): string { if (n.kind === 'office') { const cap = n.capacity; 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 ?? []) { const isOffice = rc.kind === 'office'; + const adLabel = cap === null ? '' : `A/D ${ad.length}/${cap}`; push({ kind: 'run', - label: rc.label, - sub: isOffice ? (cap === null ? '' : `A/D ${ad.length}/${cap}`) : '', + label: isOffice && owner ? owner.name : rc.label, + 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 * 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, 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, // 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. @@ -279,7 +324,15 @@ export function divisionSvg(nodes: DivisionView[]): string { const full = c.cap !== null && c.trains.length >= c.cap; out += ``; out += ``; - out += `${esc(c.label)}`; + /** + * 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 += `${esc(c.label + suffix)}`; out += rail(c.x + 6, c.y + 32, c.x + c.w - 6); if (c.sub) out += `${esc(c.sub)}`; @@ -1090,6 +1143,10 @@ export const BOARD_CSS = ` .bs-cn{fill:#e6e9ee;font:600 11px ui-monospace,monospace} .bs-coord{fill:#5f6b7a;font:9px 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.bs-full{fill:#e0a060;font-weight:600} .bs-grade{fill:#e08060;font:10px ui-monospace,monospace} diff --git a/src/sim/replay.ts b/src/sim/replay.ts index 516bdbb..c6d01cb 100644 --- a/src/sim/replay.ts +++ b/src/sim/replay.ts @@ -513,7 +513,10 @@ function render() { 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. $('grid').innerHTML = officeSvg(CELLS, f.runningRow, [], [], f.limits); diff --git a/src/sim/view.ts b/src/sim/view.ts index 5e4144a..27c457b 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -370,6 +370,23 @@ export type Frame = { * player order once §4.4's D12 decided who sits where. */ 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`. */ handCount: number; /** @@ -1241,6 +1258,12 @@ export function snapshot( revenue: p.revenue, 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, overHandLimit: (s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT), diff --git a/src/web/lobby.ts b/src/web/lobby.ts index 4cfbc9f..b5f77c4 100644 --- a/src/web/lobby.ts +++ b/src/web/lobby.ts @@ -66,7 +66,7 @@ export function runLobby(onReady: (r: LobbyReady) => void): 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; let html = ''; @@ -89,6 +89,24 @@ export function runLobby(onReady: (r: LobbyReady) => void): void { } $('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 = $('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('.lb-bot-add'))) { btn.onclick = () => void postJson('/api/lobby/bot', { token, seat: Number(btn.dataset['seat']), filled: true }); } diff --git a/src/web/main.ts b/src/web/main.ts index dfce40b..33c5e5a 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -408,6 +408,33 @@ function applyCapabilities(): void { 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 * has a reason on screen instead of silence. Always empty for a `LocalSession` (`presence()` never @@ -464,7 +491,12 @@ function render(): void { renderHouseRules(f.houseRules); // -- division - $('division').innerHTML = divisionSvg(f.division); + $('division').innerHTML = divisionSvg(f.division, { + players: f.players, + actor: f.actor, + viewer: f.viewer, + }); + renderSeatingChain(f); applyZoom($('division')); // -- board. Both renderers are shared with the replay so the two can never draw different diff --git a/src/web/play.html b/src/web/play.html index 1dc1787..98a2d66 100644 --- a/src/web/play.html +++ b/src/web/play.html @@ -35,6 +35,10 @@ header button:disabled{opacity:.45;cursor:not-allowed;border-color:#2c333d} header button:disabled:hover{border-color:#2c333d} .zoom{display:inline-flex;align-items:center;gap:4px} .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} .build{margin-left:auto;font-size:10px;opacity:.55;white-space:nowrap} .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; 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} +/* 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); color:#f2e6cf;font-weight:600} #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}