diff --git a/CHANGELOG.md b/CHANGELOG.md index b53edd2..09857a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,125 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.8.1.0 — 2026-09-22 + +A second-digit bump, and a deliberate one. **0.8.1 had been reserved for the seatless display +table**; that work is getting more thought, and this batch — a table pass over v0.8.0.17 — earned +the number on its own. Six reports, one of which turned out to be a rules question, one a wording +complaint with a real bug underneath, and four straightforward. + +### A car cleared from a red Inbound box came back still loaded + +Reported as a wording problem — *"the option to send the loaded coach in the red inbound box to the +classification yard is technically accurate but doesn't make any sense"* — and the wording was only +the visible half. + +`inboundCleared` did this: + +```ts +// `pooled` — a car back in a yard is back in the common supply, carrying nothing (state.ts). +s.yards.classificationYard.push(pooled(e.stock)); +``` + +**`pooled` does not do that.** It strips the load's `origin` stamp and keeps `loaded` on purpose, +because a train can retire at a Division Point with freight still aboard (`advance.ts`). So the +comment described an intention the call never carried out, and every car the Freight Agent cleared +reached the Classification Yard still carrying a load that had already been delivered and already +been paid for. + +It bites hardest on coaches. `passengersDetrained` takes `type === 'coach' && !loaded` out of the +Division Yard, and §2.2 refills the Division Yard from Classification — so a cleared coach came back +as stock that could never unload another passenger. Measured over five three-Day solitaire games +before the fix: **18 loaded coaches in the Classification Yard against 6 empty.** + +The red box is where a journey ENDS. Clearing it sends the passengers out of the station, or the +delivered load into the industry, and returns the **car** — empty — to the common supply. The +option now says that rather than describing the counter that moves: *"let the passengers out of the +station — the coach in the red Inbound box at (2,-1) goes back to the Classification Yard empty"*. + +**Not everything loaded in a yard is this bug**, and the fix is scoped to the red box. A train that +completes its run returns its cars as they stand, loaded ones included — that is `retireTrain`, it +is what `pooled`'s own documentation describes, and a loaded car in a yard is pre-loaded cargo +rather than dead stock: it can be made up into a train and delivered, and a loaded coach can still +detrain. Only the red box's contents had already finished their journey. + +**Games in progress DO resume, and it was measured rather than argued.** What is in the yards +changes, so the worry was real: a save whose later moves depend on a cleared car being loaded would +stop replaying. All twelve saves on the test server were pulled and replayed through +`tryResumeSession` — the server's own boot check — against this build. Six resume and six refuse, +and **the six refusals are the same six, at the same moves, with the same codes**, that 0.8.0.17 +already logged: three `CARD_NOT_IN_HAND` at move 3 in saves from engines 0.5.3, 0.7.0 and 0.7.1, +two `NO_PENDING_DECISION` from the Gitea#26 clearance fix, and WHISTLE-6945 stopping at move 529 on +v0.8.0.14's Modifier ruling. Nothing new was stranded. + +**That replay is the cheap check to repeat on any release that touches state**, and it is a better +one than reading the next boot log: it answers before the install rather than after it. + +### The history said "Mainline card 7" and left you to remember what that was + +*"It should specify both. Note there were two plains cards dealt in this hand."* — which is exactly +why both: the name informs, and the slot is the only thing telling two Plains apart. + +The line also had a second fault nobody reported, one word to its left. It built the card's name as +`e.key.replace(/([A-Z])/g, ' $1')`, so `absSignals` printed as **"abs Signals"** — uncapitalised, +and bypassing the authored names `cardName` started honouring in v0.8.0.17 for precisely this +reason: no key-splitter recovers an acronym. The action list one line above it said "ABS Signals" +while the log said "abs Signals". + +Both are resolver-shaped, so `narrate` takes `mainlineAt` and `enhancementName` alongside the +`facilityAt` it already had, and `simpleCardName` is exported so the log reads the same table the +buttons do. `mainlineModified` had both faults in the neighbouring case and is fixed with it. + +### Leaving a running game was a dead end + +*"I see the games that I'm in, and I have the ability to rejoin or forget. But what if I want to +join a different game or start a new one? How do I get back there?"* + +There was no way. `enterSeating` hides `#lb-choice-section`, and the only things that put it back +were the lobby's own two leave paths. Leaving a **running** game is a third route — `main.ts` calls +`runLobby` again — so the section stayed hidden from the previous seating and the lobby came back +holding nothing but "Games you are in". The two doors were on the page the whole time, at +`display:none`, with no control that could reveal them. + +Reset in `runLobby` rather than in the leave handler, because that function is the one thing every +route onto this screen goes through — which is why the two paths that did it themselves missed a +third. + +### The lobby's buttons say they are buttons + +*"Create new game should have that same amber border when it's enabled and ready to click, just +like the other action buttons in the rest of the game."* + +`#actions button` has meant "this is a move you can make" all game. Look up game, Join this game, +Create new game, Copy code, Copy invite link, Start game and Rejoin now carry it. The selector is a +list of those actions rather than `#lobby button`: the settings form under Create is a field of +inputs, and amber on all of it would say everything is a move and so say nothing. A disabled Start +game drops back to plain chrome, because the amber is a promise the button would not keep. + +### The Department refill is a rule, and now it is written down + +*"Why was a department slot refilled? If I take the last card there, then there should just be no +cards available in that stack."* + +The game is right and the documentation was silent. §6.2: *"If any of the Department decks is empty, +draw a Home Office card and place it in the empty spot."* It fires only when the draw actually +empties the pile — refilling on every draw would grow the Departments without limit and drain the +Home Office deck into them. **Kept as implemented** (Jesse's ruling), and stated in `rules.md` and +`home-deck.md`, neither of which had ever mentioned it. A rule implemented from the prototype and +never written down is a rule that surprises the table. + +### Games in the Manage Game dropdown say how old they are + +*"I've got four games out there, but I have no idea which one's which or how old they are."* The +label carried the code, the seat count and the position — everything except the fact that says +whether a game is live or abandoned. `lastMoveAt` was already on the wire from the server's +`summary()` and simply unused. + +Both forms, because they answer different questions: **"5h ago"** answers the one that was asked, +and the timestamp beside it is what you quote when deciding to end a game. A lobby reports when it +was created and says so. The date formatter moved to `utils.ts`, shared with Games in Progress so +the two cannot drift. + ## 0.8.0.17 — 2026-09-21 Four things a table found on Day 1 of v0.8.0.16, all of them the same shape: the game knew diff --git a/docs/components.md b/docs/components.md index 25c6979..21b65dc 100644 --- a/docs/components.md +++ b/docs/components.md @@ -1,6 +1,6 @@ # Station Master — Components and Markers -**Describes the game as built at v0.8.0.17** (2026-09-21). These references are kept current with +**Describes the game as built at v0.8.1.0** (2026-09-22). These references are kept current with every release rather than versioned as editions, so there is no version in the filename: this file is always the latest, and the build it describes is stated here. diff --git a/docs/design.md b/docs/design.md index 1e537bb..5aa5a7b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -68,7 +68,7 @@ must do. ## Current status -**v0.8.0.17.** Rules formalized, card faces specified, architecture documented, and the game +**v0.8.1.0.** Rules formalized, card faces specified, architecture documented, and the game playable **solitaire and multiplayer** in a browser against an authoritative server. See [`../CHANGELOG.md`](../CHANGELOG.md) for what each version changed and [`../TODO.md`](../TODO.md) for what is open; this section is the shape of the project, not a running tally, because a diff --git a/docs/home-deck.md b/docs/home-deck.md index 100f342..0b47cb6 100644 --- a/docs/home-deck.md +++ b/docs/home-deck.md @@ -1,6 +1,6 @@ # Station Master — Home Deck -**Describes the game as built at v0.8.0.17** (2026-09-21). These references are kept current with +**Describes the game as built at v0.8.1.0** (2026-09-22). These references are kept current with every release rather than versioned as editions, so there is no version in the filename: this file is always the latest, and the build it describes is stated here. @@ -22,7 +22,9 @@ rules are for playing each kind of card out of it. - **Home Office deck** — face down. The pile a Draw comes from. - **Three Departments** — face-up discard piles. A discard goes onto one, which is precisely so a - rival may take it; a Draw may take the top card of a Department instead of the deck. + rival may take it; a Draw may take the top card of a Department instead of the deck. **Taking the + last card off a Department refills it immediately** from the Home Office deck (§6.2), so there + are always three piles to choose from; see the Rules. - **Salvage Yard** — where a played-out card ends up. An Extra's card goes here after its run. When the Home Office deck runs out it is rebuilt from the Salvage Yard and **all three Departments diff --git a/docs/mainline-deck.md b/docs/mainline-deck.md index c907b4a..e07b4ad 100644 --- a/docs/mainline-deck.md +++ b/docs/mainline-deck.md @@ -1,6 +1,6 @@ # Station Master — Mainline Deck -**Describes the game as built at v0.8.0.17** (2026-09-21). These references are kept current with +**Describes the game as built at v0.8.1.0** (2026-09-22). These references are kept current with every release rather than versioned as editions, so there is no version in the filename: this file is always the latest, and the build it describes is stated here. diff --git a/docs/quickstart.md b/docs/quickstart.md index cc7d1fc..cdf1a46 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,6 +1,6 @@ # Station Master — Quickstart -**For a tester who has never played. Describes the game as built at v0.8.0.17** (2026-09-21). +**For a tester who has never played. Describes the game as built at v0.8.1.0** (2026-09-22). Read this once before you sit down. It is about twenty minutes of reading and will save you an hour of confusion. The deeper references are listed at the end. diff --git a/docs/rules.md b/docs/rules.md index 5868f6e..78aefe6 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -1,6 +1,6 @@ # Station Master — Rules -**Describes the game as built at v0.8.0.17** (2026-09-21). These references are kept current with +**Describes the game as built at v0.8.1.0** (2026-09-22). These references are kept current with every release rather than versioned as editions, so there is no version in the filename: this file is always the latest, and the build it describes is stated here. @@ -204,9 +204,25 @@ roll a tail cut into a connected Freight Facility. **Draw.** Take one card from the face-down Home Office or the exposed top of one Department pile. During this option, play eligible cards and/or discard cards to Department piles, then finish at the hand limit. Track, facilities, offices, modifiers, enhancements, and train cards have the placement or scheduling rules in the deck references. Mainline modifiers are played from this option as well. +> **A Department that is emptied is refilled at once.** §6.2: *"If any of the Department decks is +> empty, draw a Home Office card and place it in the empty spot."* So taking the last card off a +> pile does not leave a gap — a fresh card is turned face up from the Home Office deck to replace +> it, and there are always three Departments to draw from. The refill happens **only** when the +> draw actually empties the pile; a pile with cards buried under the one taken is not refilled, or +> the Departments would grow without limit and drain the deck into themselves. +> +> This surprised a player at the table (2026-09-22), which is why it is written down here: the rule +> was implemented from the prototype rules and had never reached this document. + **Freight Agent.** Make one of these operations, then the turn ends: stock one green outbound box from a matching loaded Division Yard car; clear one red inbound box to the Classification Yard; unjam one outbound, inbound, or MEN | AT | WORK load to the Classification Yard; or explicitly end without acting. Freight can be stocked only when an unclaimed empty matching car is already spotted at that industry. Passengers may wait in a green Office box without a train present. -Implementation note, still true at v0.8.0.17: `card.discard` is accepted during Local Operations +> **A car cleared from a red Inbound box comes back empty.** Whatever was in it has arrived — the +> passengers are out of the station, or the load is in the industry — and the Revenue for it was +> paid on arrival, not on clearing. What returns to the Classification Yard is the *car*, in the +> common supply and carrying nothing. Before v0.8.1.0 it returned still marked loaded, which put +> coaches into the yards that could never be used to unload another passenger. + +Implementation note, still true at v0.8.1.0: `card.discard` is accepted during Local Operations without checking that the Draw option was chosen — unlike `card.play`, which does check. This is an implementation quirk rather than a fourth published turn option. diff --git a/package.json b/package.json index 2e15e04..72556d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.8.0.17", + "version": "0.8.1.0", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/engine/apply.ts b/src/engine/apply.ts index ae6a379..bcb528b 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -2561,8 +2561,28 @@ export function reduce(s: GameState, e: GameEvent): void { const f = facilityAt(s, e.player, e.at)!; const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded); if (idx >= 0) f.inboundBox.splice(idx, 1); - // `pooled` — a car back in a yard is back in the common supply, carrying nothing (state.ts). - s.yards.classificationYard.push(pooled(e.stock)); + /** + * THE CAR COMES BACK EMPTY, and until v0.8.1.0 it did not. + * + * The line here said "`pooled` — a car back in a yard is back in the common supply, carrying + * nothing", and `pooled` does no such thing: it strips the `origin` stamp and DELIBERATELY + * keeps `loaded`, because a train can retire at a Division Point with freight still aboard + * (`advance.ts`). So the comment described an intent the call never carried out, and every + * car the Freight Agent cleared went to the Classification Yard still loaded. + * + * That is not cosmetic. `passengersDetrained` needs `type === 'coach' && !loaded` out of the + * Division Yard, and §2.2 refills the Division Yard from Classification — so a coach cleared + * from a red box came back as stock that could never unload another passenger. Measured over + * five three-Day solitaire games before the fix: 18 loaded coaches in the Classification Yard + * against 6 empty, and 9 loaded in the Division Yard against 33 empty. + * + * The red box is where the journey ENDS for what is in it — the Revenue was paid when the + * load arrived or the passengers detrained (`view.ts`, `freightAgent.clearInbound`). Clearing + * it sends the passengers out of the station, or the delivered load into the industry, and + * returns the CAR to the common supply. A car in a yard carrying a load nobody can unload is + * not supply. + */ + s.yards.classificationYard.push({ ...pooled(e.stock), loaded: false }); turnOf(s, e.player).freightAgentUsed = true; break; } diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index 0acc9bd..501665d 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -144,6 +144,17 @@ export type NarrateContext = { * industry is the whole point of the move, so it is what the line should say. */ facilityAt?: (player: PlayerIndex, at: GridCoord) => string | null; + /** + * Names the Mainline card at a Division node — "Plains", "Heavy Grade" — or null if it cannot. + * + * A NODE INDEX IS NOT A NAME. The log said "ENHANCEMENT built: abs Signals on Mainline card 7", + * which asks the reader to remember what card 7 was (Jesse, 2026-09-22, playing ABS Signals). + * The SLOT still matters and is kept beside it, because a division can hold two Plains and the + * number is the only thing that tells them apart. + */ + mainlineAt?: (node: number) => string | null; + /** The printed name of an Enhancement or Mainline modifier, by its content key. */ + enhancementName?: (key: string) => string | null; }; export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { @@ -152,6 +163,12 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { // The industry on a square when there is one, and the coordinate when there is not — a crew works // plain track too, and "at nowhere" would be worse than the notation. const place = (player: PlayerIndex, c: GridCoord): string => ctx.facilityAt?.(player, c) ?? at(c); + /** "Mainline card 7 (Plains)" — the slot, which disambiguates, plus the card, which informs. */ + const mainlineCard = (node: number | undefined): string => { + if (node === undefined) return 'the Mainline'; + const name = ctx.mainlineAt?.(node) ?? null; + return `Mainline card ${node}${name ? ` (${name})` : ''}`; + }; switch (e.type) { // -- clock @@ -321,7 +338,10 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { tone: 'plain', text: e.became ? `Realignment: Mainline card ${e.node}, ${kindName(e.from)}, converted to ${kindName(e.became)}` - : `Played ${e.key} on Mainline card ${e.node}${e.from ? ` (${kindName(e.from)})` : ''}`, + // Same two faults as `enhancementPlaced` above, in the neighbouring case: a raw key for a + // name, and a slot with nothing to say which card it is when `from` is absent. + : `Played ${ctx.enhancementName?.(e.key) ?? e.key} on Mainline card ${e.node}` + + ` (${e.from ? kindName(e.from) : (ctx.mainlineAt?.(e.node) ?? 'that card')})`, }; } case 'redFlagSpent': @@ -371,11 +391,17 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { text: `Extra X${e.trainNumber} played — it is NOT scheduled; it runs once as soon as a Crew Tray frees up, then its card is gone`, }; case 'enhancementPlaced': { - const name = e.key.replace(/([A-Z])/g, ' $1'); + /** + * THE CARD'S OWN NAME, not a key with spaces wedged into it. This was + * `e.key.replace(/([A-Z])/g, ' $1')`, which rendered `absSignals` as "abs Signals" — + * uncapitalised, and bypassing the authored names `cardName` started honouring in v0.8.0.17 + * for exactly this reason: an acronym is not recoverable by splitting on capitals. + */ + const name = ctx.enhancementName?.(e.key) ?? e.key.replace(/([A-Z])/g, ' $1'); // Out on the Mainline is not a square in anyone's district, so it is named rather than // given a coordinate the Office Area does not have. if (e.at === undefined) { - return { tone: 'good', text: `ENHANCEMENT built: ${name} on Mainline card ${e.node}` }; + return { tone: 'good', text: `ENHANCEMENT built: ${name} on ${mainlineCard(e.node)}` }; } return { tone: 'good', where: e.at, text: `ENHANCEMENT built: ${name} at ${at(e.at)}` }; } diff --git a/src/sim/view.ts b/src/sim/view.ts index 9c01586..4b16b38 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -1150,8 +1150,20 @@ export function describeIntent(s: GameState, i: Intent): string { ? 'the Revenue was paid when the passengers detrained' : 'the Revenue was paid when the load reached the box'; const frees = f?.kind === 'passenger' ? 'more passengers can detrain here' : 'another car can be unloaded here'; + /** + * SAY WHAT HAPPENS, NOT WHAT MOVES (Jesse, 2026-09-22). This read "send the loaded coach in + * the red Inbound box to the Classification Yard", which is true of the counters and + * nonsense as a sentence — it sounds like shipping a coachful of people off to a siding. + * What the red box holds is a journey that has ENDED: the passengers are at the station, or + * the load is at the industry, and both were paid for on arrival. Clearing it sends them on + * their way and returns the CAR to the common supply, empty. + */ + const what = + f?.kind === 'passenger' + ? `let the passengers out of the station — the ${car ? carLabel(car) : 'coach'} in the red Inbound box at ${at(i.at)} goes back to the Classification Yard empty` + : `send the delivered load into the industry — the ${car ? carLabel(car) : 'car'} in the red Inbound box at ${at(i.at)} goes back to the Classification Yard empty`; return ( - `send the ${car ? carLabel(car) : 'car'} in the red Inbound box at ${at(i.at)} to the Classification Yard` + + `${what}` + ` — pays nothing (${paid}); it frees${full ? ' the last' : ' a'} slot so ${frees}` ); } @@ -1935,6 +1947,17 @@ export function cardName(s: GameState, id: string): string { * Built from the content tables rather than restated, so a card renamed there is renamed here and * the two cannot drift — which is the whole argument of TODO #15a, applied to names. */ +/** + * The printed name of a card that has one, by its content key, or null. + * + * Exported so the LOG can reach the same table the buttons do. `narrate.ts` rendered its own name + * from the key (`absSignals` → "abs Signals") and so disagreed with the action list one line above + * it; it takes this through its `enhancementName` resolver now. + */ +export function simpleCardName(key: string): string | null { + return SIMPLE_CARD_NAMES.get(key) ?? null; +} + const SIMPLE_CARD_NAMES: ReadonlyMap = new Map( [ ...SPACE_USE_CARDS, diff --git a/src/web/game.ts b/src/web/game.ts index 8c861ce..75e2bd0 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -37,16 +37,7 @@ import { collectStep, newCollector } from '../sim/display-step.ts'; import type { DisplayCollector } from '../sim/display-step.ts'; // Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv, // which would pull node:fs into a browser bundle. -import { - cardDescription, - cardName, - currentActorOfState, - describeIntent, - geometryLabel, - snapshot, - trainName, - variantLabel, -} from '../sim/view.ts'; +import { cardDescription, cardName, currentActorOfState, describeIntent, geometryLabel, simpleCardName, snapshot, trainName, variantLabel } from '../sim/view.ts'; import { DEFAULT_DAYS, DEFAULT_HOUSE_RULES, @@ -1354,6 +1345,19 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul cardName: (id) => cardName(game.state, id), trainName: (id) => trainName(game.state, id), facilityAt: (player, at) => facilityOn(game, player, at), + /** + * Which Mainline card a Division node holds. The log used to print the node INDEX alone — + * "on Mainline card 7" — leaving the reader to remember what card 7 was (Jesse, 2026-09-22). + * The index is kept beside the name because a division can deal two Plains, and then the slot + * is the only thing that distinguishes them. + */ + mainlineAt: (node) => { + const n = game.state.division.nodes[node]; + return n?.kind === 'mainline' ? mainlineProfile(n.card).name : null; + }, + // The printed name, for the same reason `cardName` stopped trusting `prettyKey` in v0.8.0.17: + // no key-splitter recovers "ABS Signals" from `absSignals`. + enhancementName: (key) => simpleCardName(key), // Whose district a train reached is not the actor — the Mainline Phase has none — so the // narration resolves the name itself rather than being prefixed with one by the code below. // NO NUMBER IN THE FALLBACK. This is a PLAYER index, and a player is not a seat — seats rotate diff --git a/src/web/lobby.ts b/src/web/lobby.ts index e1bf4ed..c1e1bdd 100644 --- a/src/web/lobby.ts +++ b/src/web/lobby.ts @@ -131,6 +131,22 @@ export function runLobby(handlers: LobbyHandlers, resume?: { token: string; game // stub only part of the DOM. Bail rather than throwing through the module's caller. if (!has('lobby') || !has('lb-choice-section')) return; $('lobby').hidden = false; + /** + * THE TWO DOORS COME BACK EVERY TIME THIS SCREEN DOES. + * + * `enterSeating` hides this section, and until v0.8.1.0 the only things that put it back were the + * lobby's OWN leave paths. Leaving a running game is a different route — `main.ts`'s Leave game + * button calls `runLobby` again — so the section stayed hidden from the previous seating and the + * screen came back holding nothing but "Games you are in". Reported 2026-09-22: "I see the games + * that I'm in, and I have the ability to rejoin or forget. But what if I want to join a different + * game or start a new one? How do I get back there?" There was no way: the doors were on the + * page, display:none, with no control that could reveal them. + * + * Reset here rather than in the leave handler, because this function is the one thing every route + * onto the lobby goes through — which is exactly why the two that did it themselves missed a third. + */ + $('lb-choice-section').hidden = false; + if (has('lb-seating-section')) $('lb-seating-section').hidden = true; const form = settingsForm('lb-'); let source: EventSource | null = null; diff --git a/src/web/play.html b/src/web/play.html index 821406c..b3b5c6c 100644 --- a/src/web/play.html +++ b/src/web/play.html @@ -343,6 +343,27 @@ ul.blocked li{padding:2px 0} .lb-door:hover{border-color:#4d6fa8} .lb-door.active{background:#2f3a4b;border-color:#6f8fc8;color:#cfe0f5;font-weight:600} .lb-saved{display:flex;align-items:center;gap:10px} +/* THE LOBBY'S ACTION BUTTONS CARRY THE SAME AMBER AS THE BOARD'S (Jesse, 2026-09-22). + `#actions button` has meant "this is a move you can make" all game; the lobby is the one screen + where a player is also being asked to act, and it was styling those buttons as plain chrome. The + selector is a LIST OF THE ACTIONS, not `#lobby button` — the settings form beneath Create is a + field of inputs, and amber on all of it would say everything is a move and so say nothing. + Disabled keeps `button:disabled` above: an unfilled Start game must not advertise a click. */ +#lb-look, #lb-join, #lb-create, #lb-start, #lb-copy, #lb-copylink, .lb-known-row .lb-rejoin{ + background:#2b3444;border:2px solid #c8912f;box-shadow:0 0 0 1px rgba(200,145,47,.18); + color:#f2e6cf;font-weight:600} +#lb-look:hover, #lb-join:hover, #lb-create:hover, #lb-start:hover, #lb-copy:hover, +#lb-copylink:hover, .lb-known-row .lb-rejoin:hover{ + background:#3a4a63;border-color:#f0b64a;box-shadow:0 0 0 3px rgba(240,182,74,.20)} +#lb-look:focus-visible, #lb-join:focus-visible, #lb-create:focus-visible, #lb-start:focus-visible, +#lb-copy:focus-visible, #lb-copylink:focus-visible, .lb-known-row .lb-rejoin:focus-visible{ + outline:2px solid #f0b64a;outline-offset:2px} +/* Copy is still secondary — it carries the amber so it reads as actionable, but not the weight of + the button that starts a game. `button.ghost` set its own border, so it is overridden here. */ +#lb-copy, #lb-copylink{border-width:1px;font-size:11px;box-shadow:none} +/* A disabled action is chrome again, or the amber becomes a promise the button will not keep. */ +#lb-start:disabled, #lb-start:disabled:hover{ + background:#2a3038;border:2px solid var(--line);box-shadow:none;color:inherit} .lb-known-row{display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid var(--line)} .lb-known-row:last-child{border-bottom:none} .lb-known-row .code{font-weight:700;letter-spacing:.06em;color:#f2e6cf;flex:1} diff --git a/test/apply.test.ts b/test/apply.test.ts index 2b0b587..fc65105 100644 --- a/test/apply.test.ts +++ b/test/apply.test.ts @@ -798,6 +798,44 @@ describe('Freight Agent operations (§6.3)', () => { ); }); + it('returns a cleared car to the yard EMPTY, not still loaded', () => { + /** + * REGRESSION, v0.8.1.0. `inboundCleared` pushed `pooled(e.stock)` under a comment reading + * "a car back in a yard is back in the common supply, carrying nothing" — and `pooled` does not + * do that. It strips the `origin` stamp and keeps `loaded` DELIBERATELY, because a train can + * retire at a Division Point with freight still aboard (`advance.ts`). So every car the Freight + * Agent cleared arrived in the Classification Yard still carrying a load that had already been + * delivered and already paid for. + * + * It bites hardest on coaches: `passengersDetrained` takes `type === 'coach' && !loaded` out of + * the Division Yard, and §2.2 refills the Division Yard from Classification — so a cleared + * coach came back as stock that could not unload a passenger. + */ + const s = game(); + const coord = withFacility(s); + const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!; + f.allows = { outbound: false, inbound: true }; + f.inboundBox = [{ type: 'coach', loaded: true }]; + + const before = s.yards.classificationYard.length; + assert.ok(applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' }).ok); + assert.ok( + applyIntent(s, 0, { type: 'freightAgent.clearInbound', at: coord, index: 0 }).ok, + 'the Freight Agent could not clear the red box', + ); + + assert.equal(f.inboundBox.length, 0, 'the red box was not cleared'); + assert.equal(s.yards.classificationYard.length, before + 1, 'the car did not reach the yard'); + const arrived = s.yards.classificationYard[s.yards.classificationYard.length - 1]!; + assert.equal(arrived.type, 'coach', 'the wrong car reached the yard'); + assert.equal(arrived.loaded, false, 'the cleared car arrived in the yard still loaded'); + // And a coach that arrives empty is stock a Porter can actually use again. + assert.ok( + s.yards.classificationYard.some((c) => c.type === 'coach' && !c.loaded), + 'no empty coach reached the common supply', + ); + }); + it('makes the Freight Agent option unavailable with nothing to operate (§6)', () => { // A player with no Facility cannot choose an option that has no possible follow-up. const s = game(); diff --git a/test/events.test.ts b/test/events.test.ts index 5f38892..09b50be 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -241,3 +241,45 @@ describe('a Freight Agent turn says what it did, to what, and where', () => { assert.match(plain.text, /\(4,-2\)/, `the fallback coordinate is not in X,Y order: ${plain.text}`); }); }); + +/** + * WHICH MAINLINE CARD, AND WHAT THE CARD IS CALLED. + * + * Reported 2026-09-22, playing ABS Signals: "In the history, it referred to it as Mainline card 7, + * but didn't give the actual card type, which was plains. It should specify both. Note there were + * two plains cards dealt in this hand." Both halves matter for that reason — the name informs, the + * slot is the only thing telling two Plains apart. + */ +describe('the log names the Mainline card an Enhancement was built on', () => { + const ctx = { + mainlineAt: (node: number) => (node === 7 ? 'Plains' : node === 3 ? 'Heavy Grade' : null), + enhancementName: (key: string) => (key === 'absSignals' ? 'ABS Signals' : null), + }; + + it('gives the slot AND the card type', () => { + const line = narrate({ type: 'enhancementPlaced', player: 0, key: 'absSignals', node: 7 } as never, ctx); + assert.match(line.text, /Mainline card 7/, `the slot is gone: ${line.text}`); + assert.match(line.text, /Plains/, `the card type is missing: ${line.text}`); + }); + + it('calls the card by its printed name, not a split key', () => { + const line = narrate({ type: 'enhancementPlaced', player: 0, key: 'absSignals', node: 7 } as never, ctx); + assert.match(line.text, /ABS Signals/, `not the printed name: ${line.text}`); + assert.ok(!/abs Signals/.test(line.text), `still splitting the key: ${line.text}`); + }); + + it('still says something useful when the card cannot be resolved', () => { + // No resolver at all — an engine test narrating events has no division to ask. + const bare = narrate({ type: 'enhancementPlaced', player: 0, key: 'absSignals', node: 2 } as never, {}); + assert.match(bare.text, /Mainline card 2/, `the slot must survive with no resolver: ${bare.text}`); + }); + + it('leaves a grid-square Enhancement naming its coordinate', () => { + const line = narrate( + { type: 'enhancementPlaced', player: 0, key: 'smallYard', at: { row: -1, col: 2 } } as never, + { enhancementName: (k: string) => (k === 'smallYard' ? 'Small Yard' : null) }, + ); + assert.match(line.text, /Small Yard/, `not the printed name: ${line.text}`); + assert.match(line.text, /\(2,-1\)/, `the square is gone or in the wrong order: ${line.text}`); + }); +}); diff --git a/test/web.test.ts b/test/web.test.ts index d08a02f..a7734a4 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -5726,6 +5726,39 @@ describe('the Quickstart guide reaches the site', () => { assert.match(splash, /href="\.\/quickstart\.md"/, 'the splash page does not link the guide'); }); + it('brings the lobby doors back every time the lobby is shown', () => { + /** + * REPORTED 2026-09-22: "After leaving a game, I go back to the lobby. I see the games that I'm + * in, and I have the ability to rejoin or forget. But what if I want to join a different game + * or start a new one? How do I get back there?" + * + * `enterSeating` hides `#lb-choice-section`, and until v0.8.1.0 only the lobby's OWN leave + * paths put it back. Leaving a RUNNING game is a third route — `main.ts` calls `runLobby` again + * — so the section stayed hidden from the previous seating and the screen came back holding + * nothing but "Games you are in", with the two doors on the page at `display:none` and no + * control that could reveal them. + * + * Read out of the source, like the MIME table above: the fix is an ordering property of one + * function, and there is no DOM harness here that drives `runLobby` end to end. What this pins + * is that the reset exists inside `runLobby` and happens BEFORE the resume path re-enters + * seating — reversing those two would hand a resuming browser the wrong screen. + */ + const src = readFileSync(join(root, 'src/web/lobby.ts'), 'utf8'); + const body = src.slice(src.indexOf('export function runLobby')); + assert.ok(body.length > 0, 'runLobby is gone'); + + const reset = body.indexOf("$('lb-choice-section').hidden = false"); + assert.ok(reset > 0, 'runLobby never reveals the choice section, so leaving a game is a dead end'); + + const resume = body.indexOf('if (resume) enterSeating'); + assert.ok(resume > 0, 'the resume path is gone'); + assert.ok(reset < resume, 'the reset runs after the resume, which would show a seated browser the doors'); + + // And the seating section is put away with it, or the two would be on screen together. + const seating = body.indexOf("$('lb-seating-section').hidden = true"); + assert.ok(seating > 0 && seating < resume, 'the seating section is not reset before resume'); + }); + it('publishes everything the guide links, so "Where to read more" is not five dead links', () => { /** * v0.8.0.16 published the Quickstart alone. Its §8 links five further documents by relative