diff --git a/CHANGELOG.md b/CHANGELOG.md index 2622945..4d6dde7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,117 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.7.9.6 — 2026-09-07 + +Three things the engine knew and the screen did not, found by looking for them rather than by +waiting to be told — plus the dead-field audit from 0.7.9 finished off. + +### The method, first, because it is the part that generalises + +Gitea#21, Gitea#22, #94 and #96 were four instances of one fault in a row: the engine gains something +that changes what a train may do, and nothing draws it. Every one was found by a player hitting it. +So rather than wait for the fifth, every field of `GameState` and its nested types was enumerated and +checked for a reader in `sim/view.ts`, `src/web/` and `sim/narrate.ts` — and the survivors were then +**verified by running the engine**, not by trusting the grep. Four fields had no reader anywhere. +`movedThisPhase` is set and cleared inside a single `advance` call and is nobody's business. The +other three are below. + +Saying what was ruled out matters as much: `freightWorked`, `drawnThisTurn`, `freightAgentUsed`, +`switchedSince` and `movesUsed` are all invisible on purpose — their effect already shows as +legality, or as a complement already on the Frame. A field is not a display gap merely because +nothing renders it. `dispatchUsedToday` is the one genuine maybe left, and is not done. + +### The Crew Tray pool is a mechanic you can now see (#98) + +`state.ts` calls §7's tray scarcity "an explicit mechanic" and it was explicit only in the engine. +The blocked panel — the one that answers "why is nothing moving?" — had exactly one tray rule, keyed +off the train due out this Stage. So a player who had spent a card on an Extra, or ordered a second +section, got a **completely empty** panel while their train sat behind an exhausted pool. Both had +been announced once in the log, in a line that promised a future event — "it runs once as soon as a +Crew Tray frees up", "an identical train will run right behind it" — which nothing ever confirmed. + +`projectSharedTable` carries `crewTrays` and `queued` now, so the common board gets it too, and the +panel reports all three cases with the count beside them: "no free Crew Tray" on its own reads like a +permanent fact about the game rather than a state that will pass. + +The first draft of that count derived the pool size as `trays.size + freeTrays.length`. That is +invariant in play — `retireTrain` puts the tray back — and reads **"0 of 0"** the moment it meets a +state where a tray is neither free nor carrying a train. `crewTrayCount` already owned the number. + +### A train held at the Limits had been vanishing off the board (#99) + +The most serious of the three, and it had been shipped. The Interlocking is the designed answer to a +full Office: rather than Gap 2d's automatic collision, the train is stopped on the Limit Track and +takes the first A/D track that frees, ahead of any newcomer. + +`arriveAtOffice` removes the tray from the Mainline node's `transits`, and the Interlocking branch +pushes it onto `area.heldAtLimits` **without assigning `tray.position`**. The map draws mainline +nodes from `transits` and district squares from `position.at === 'grid'` — so between the two, the +train was drawn in neither. It disappeared from the board on arrival and reappeared in the Office +some Stages later, with a single log line as the whole account of it. + +Measured rather than reasoned: with the tray in `transits` the Interchange node carries its chip; +moved to `heldAtLimits` exactly as the engine moves it, that node's `trains` is `[]` and no grid +square has gained it. + +Fixed in the **view**, not the engine. The engine is right — a held train is inside the Limits and +not on an A/D track — and `position` is deliberately left alone so nothing may treat the train as +standing on a square it could be switched from. The map draws it on the Limits square it came in by +(eastbound at `limitsWest`, westbound at `limitsEast`), flagged so it does not read as an ordinary +arrival, with the reason on the chip and in the blocked panel. + +### The Campaign Train now says whether its speeches are made (#100) + +X17 is "one turn at station (speeches) then expedite" — two states, not one sentence. Its first +Office arrival is an ordinary stop; every arrival after runs expedited, and an expedited train left +off the Office square when the next Mainline Phase begins is a fault costing 1 Revenue. + +`trainRules()` took `{ trainNumber, trainIsExtra }`, so it could not see `speechMade` even though +both of its tray-side callers hand it a whole `CrewTray` that has it. The chip read identically +before and after. Worse, the "EXPEDITED … costs 1 Revenue" warning is printed only under +`rules.expedite` — so X17 became subject to a fault whose warning the game shows to every other +expedited train and never to it. It now says which half it is in, and borrows `isExpedited` from +`advance.ts` rather than restating the test. + +### The 0.7.9 dead-field audit, finished (#45) + +Deferred by Jesse on 2026-08-30 with the decision framed as delete-or-document. The answer turned out +to be different for each, and neither was a patch. + +**`overHandLimit` is wired, because its consumer existed all along and was guessing.** `main.ts` +already draws a disabled "End Local Operations" button explaining the hand limit — but decided to +draw it from the *absence* of `draw.end` in the menu. That is sound only because `check('draw.end')` +refuses for exactly three reasons and the two guards beside it rule out the other two; a fourth +reason would have made the panel explain a refusal by describing something else entirely, which is +#90 verbatim. It reads `f.overHandLimit` now — which is what the field was built for in the first +place. Behaviour is unchanged; the screen states its reason instead of inferring it. + +**`viewerSeat` is documented, with a condition.** Gitea#20's common board keys districts by seat and +resolves the player through `playerAtSeat`, so a client picking its own district out of a seat-keyed +board needs this and cannot get it from `viewer`. The declaration says so — and says to delete it if +step 2 ships without using it. + +**And the audit had missed a third limb.** `game.mustPlayCard` was assigned from `overHandLimit` on +every submit and read by nothing at all: deleted. Chasing it turned up the thing actually worth +fixing — the §6.2 hand-limit test existed in **three** places (`check('draw.end')`, an inline +recomputation inside `snapshot()`, and `web/game.ts`'s own). All three agreed, which is precisely the +state #96's disagreement started from. There is one `overHandLimit(state, player)` in `state.ts` now +and the other two ask it. `Session.overHandLimit()` — declared on the interface and implemented +twice — is deleted rather than kept, the Frame already carrying the fact. + +### Proof + +934 tests pass, up from 917. The 17 new ones were written red and each fix was then checked by +mutation: reverting `speechMade` fails 2, dropping the held-train projection fails 4, and forgetting +the two tray queues fails 2. The blocked panel returning nothing for the queues is reported alongside +its positive control — the same state with a timetabled train due, which correctly says "no free Crew +Tray" — because an empty result from a function that is simply broken proves nothing. + +**Not verified at a table.** All of this is engine and view work checked by tests and by running the +engine; no part of it has been met by a person at a board. #39 and #35 still stand. + +--- + ## 0.7.9.5 — 2026-09-07 Two faults in what 0.7.9.4 had just built, both of the same shape: a second copy of an answer that diff --git a/TODO.md b/TODO.md index 877445f..3903c57 100644 --- a/TODO.md +++ b/TODO.md @@ -363,11 +363,6 @@ are one section is that each one found the next. them. **The flag matters more than the 29** — two are Gitea#18 leftovers in one file, one found by hand and the other missed. Do #48 first; it settles ten of them. See **Reference · #46**. -- [ ] **#45** — The Frame carries two things nothing reads — `viewerSeat` and `overHandLimit`. - **Deferred by Jesse 2026-08-30** ("leave it for now"); the decision when it comes is - delete-or-document, not a patch. The two tally fields found by the same audit are fixed. See - **Reference · #45**. - - [ ] **#84** — Five test fixtures pinned a seed and meant "a game like this". All five broke on Gitea#14 for that reason. See **Reference · #84**. @@ -1584,11 +1579,33 @@ screen. Fixing them turned up something else worth knowing: `resultsHtml` draws screen, which is how the first attempt at pinning this failed for a reason unrelated to the fix. -**The two plumbing ones — `viewerSeat` and `overHandLimit` — are DEFERRED (Jesse, 2026-08-30): -"leave it for now."** They cost nothing today and v0.7.9 had enough in it. The decision when it -comes is delete-or-document, not a patch: either strip the field and the `Session`-interface -method, or write down what each is being carried ahead of a consumer FOR, so the next audit -does not re-flag them. +**The two plumbing ones are DONE — 2026-09-07 in v0.7.9.6, and the answer was different for +each.** Deferred by Jesse 2026-08-30 ("leave it for now") on the understanding that the decision +would be delete-or-document rather than a patch. + +- **`overHandLimit` — WIRED, because the consumer existed all along and was guessing.** `main.ts` + draws a disabled "End Local Operations" button explaining the hand limit, and decided to draw it + from `f.option === 'draw' && !menu.options.some(i => i.type === 'draw.end')` — i.e. from the + ABSENCE of the move. That is sound only because `check('draw.end')` refuses for exactly three + reasons and the two guards beside it rule out the other two; a fourth reason would have made the + panel explain a refusal by describing something else entirely, which is #90 verbatim. It now reads + `f.overHandLimit`, which is what `web/game.ts` says the field is for: "so the page can DISABLE the + button with a reason instead of hiding a move that has simply become illegal." Behaviour-neutral + today; what changed is that the screen states its reason instead of inferring it. +- **`viewerSeat` — DOCUMENTED, with a condition.** Gitea#20's common board keys every district by + SEAT and resolves the player through `playerAtSeat`, so a client picking its own district out of a + seat-keyed board needs this and cannot derive it from `viewer`. The declaration now says so, and + says to delete it if step 2 ships without using it — a note is a reason to survive one audit, not + an exemption from the next. + +**And the audit's own method found a third limb it had missed.** `game.mustPlayCard` was set from +`overHandLimit` on every submit and read by nothing at all — deleted. Chasing that turned up the +thing actually worth fixing: the §6.2 hand-limit test existed in **three** places — `check('draw.end')` +in `apply.ts`, an inline recomputation in `snapshot()`, and `web/game.ts`'s own. All three agreed, +which is exactly the state #96's disagreement started from. There is now one `overHandLimit(state, +player)` in `state.ts` and the other two ask it. `Session.overHandLimit()` — declared on the +interface and implemented twice, locally and remotely — is deleted rather than kept: the Frame +already carries the fact, so the method was a second path to it. #### #84 — FIVE TEST FIXTURES PINNED A SEED AND MEANT "A GAME LIKE THIS". @@ -1830,6 +1847,81 @@ the numbers stay so cross-references above and below still resolve. `Frame.lines` is the solitaire and replay path, which builds its Frames through `snapshot()` directly and is untouched. +98. ~~**The Crew Tray pool was an "explicit mechanic" that only the engine could see.**~~ — done + 2026-09-07 in v0.7.9.6. §7 scarcity is real — there are fewer trays than trains wanting one — and + the view read none of the three things the engine knows about it: how many are free, which Extras + are queued for one, which second sections are. The panel that answers "why is nothing moving?" + had a single tray rule, keyed off the train due out THIS Stage, so a player who had spent a card + on an Extra or ordered a second section got an **empty** panel while their train sat behind an + exhausted pool. Both had been announced once in the log, in a line promising a future event ("as + soon as a Crew Tray frees up") that nothing then confirmed. + + `projectSharedTable` now carries `crewTrays` and `queued`, so the common board gets it too, and + the blocked panel reports all three cases with the count beside them — "no free Crew Tray" alone + reads as a permanent fact about the game rather than a state that will pass. An Extra is reported + to the player who played the card, because §7 gives the train to them; a second section is the + table's, like any Timetabled train. + + **Worth knowing:** the first draft derived the pool size as `trays.size + freeTrays.length`, + which is invariant in play (`retireTrain` returns the tray) and read **"0 of 0"** the moment it + met a state where a tray was neither free nor carrying a train. `crewTrayCount` already owned + that number. A second way to know one fact is the shape of every bug in this release. + + **THE METHOD, which is worth more than the three fixes** (#98, #99, #100 all came out of it, and + `test/display-gaps.test.ts` cites this entry for it). Gitea#21, Gitea#22, #94 and #96 were four + instances of one fault in a row — the engine gained something that changes what a train may do, + and nothing drew it — and every one was found by a player hitting it. So instead of waiting for + the fifth: enumerate every field of `GameState` and its nested types, check each for a reader in + `sim/view.ts`, `src/web/` and `sim/narrate.ts`, then **verify the survivors by running the engine + rather than trusting the grep**. Four fields had no reader. `movedThisPhase` is set and cleared + inside one `advance` call and is genuinely nobody's business; the other three are the items + above. + + **A field is not a display gap merely because nothing renders it**, and saying so is what keeps + the sweep honest. `freightWorked`, `drawnThisTurn`, `freightAgentUsed`, `switchedSince` and + `movesUsed` were all ruled out: their EFFECT is already visible as legality, or as a complement + already on the Frame (`movesRemaining`). `dispatchUsedToday` — a once-a-Day bonus already spent, + discoverable only by the action being refused — is the one genuine maybe left, and is not done. + + The verification mattered twice. The blocked panel returning `[]` for the two queues is only + evidence alongside the positive control — the same state with a timetabled train due, which + correctly reports "no free Crew Tray". And #99's "drawn nowhere" was established by watching a + chip present on the Interchange node before the move to `heldAtLimits` and absent after. + +99. ~~**A train held at the Limits by an Interlocking vanished off the board.**~~ — done 2026-09-07 + in v0.7.9.6, and the most serious of the three. The Interlocking is the designed answer to a full + Office — instead of Gap 2d's automatic collision, "may stop an inbound train on the Limit Track", + and it takes the first A/D track that frees ahead of any newcomer. + + `arriveAtOffice` removes the tray from the Mainline node's `transits` and the Interlocking branch + pushes it onto `area.heldAtLimits` **without assigning `tray.position`**. The map draws mainline + nodes from `transits` and district squares from `position.at === 'grid'`, so between the two it + was drawn in NEITHER. Measured: with the tray in `transits` the Interchange node carries its + chip; moved to `heldAtLimits` exactly as the engine moves it, the node's `trains` is `[]` and no + grid square has gained it. The train disappeared on arrival and reappeared in the Office some + Stages later, with one log line as the only account of it. + + **Fixed in the VIEW, not the engine.** The engine's state is right — a held train is inside the + Limits and not on an A/D track — and `position` is left alone deliberately, so nothing may treat + it as standing on a square it could be switched from. `trainsOnCard` draws it on the Limits + square it came in by (eastbound at `limitsWest`, westbound at `limitsEast`), flagged + `heldAtLimits` so it does not read as an ordinary arrival, with the reason on the chip and in the + blocked panel. + +100. ~~**The Campaign Train's speeches changed its rules and the card never said which half it was + in.**~~ — done 2026-09-07 in v0.7.9.6. X17 is "one turn at station (speeches) then expedite": + its first Office arrival is an ordinary stop, and every arrival after runs EXPEDITED — so if it + is not back on the Office square when the next Mainline Phase begins, that is a Station Master + fault costing 1 Revenue. + + `trainRules()` took `{ trainNumber, trainIsExtra }` and so could not see `speechMade`, even + though both of its tray-side callers hand it a whole `CrewTray` that has it. The chip therefore + read identically before and after — and worse, the "EXPEDITED … costs 1 Revenue" warning is + printed only under `rules.expedite`, so X17 became subject to a fault whose warning the game + shows to every other expedited train and never to it. It now says which half it is in, and + borrows `isExpedited` from `advance.ts` rather than restating the test: a card describing a rule + the engine does not apply is the failure this sits inside. + 32. ~~**Tell the 0.4.9 playtesters their saves are dead, before they find out.**~~ — done 2026-09-07. `PLAYTEST-0.7.4.md` was written for exactly this and did its job; Jesse, 2026-09-07: "a temporary document to help some of the playtesters out on making the big jump, but that is no diff --git a/package.json b/package.json index e9c49ab..4962603 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.7.9.5", + "version": "0.7.9.6", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/engine/advance.ts b/src/engine/advance.ts index 41d79ad..403b26b 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -727,7 +727,11 @@ function spendDispatchBonus( * the speeches are made, and every arrival after that is expedited. `speechMade` is set on that first * stop, so the train is exempt once and subject to the fault thereafter. */ -function isExpedited(tray: CrewTray): boolean { +export function isExpedited(tray: { + trainNumber: number | null; + trainIsExtra: boolean; + speechMade?: boolean; +}): boolean { const rules = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules; if (!rules) return false; if (rules.expedite) return true; diff --git a/src/engine/apply.ts b/src/engine/apply.ts index d39650d..fd12622 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -61,6 +61,7 @@ import { decisionActor, officeNodeFor, isOperationalRail, + overHandLimit, playerAtSeat, pooled, railFacingOf, @@ -1099,10 +1100,9 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo case 'draw.end': { if (!inPhase(s, 'localOps')) return 'WRONG_PHASE'; if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN'; - // §6.2 — "the player must reduce his hand to no more than three cards". - const hand = s.decks.hands.get(player) ?? []; - const limit = s.decks.redFlags.get(player) ? HAND_LIMIT + 1 : HAND_LIMIT; - return hand.length > limit ? 'HAND_LIMIT' : null; + // §6.2 — "the player must reduce his hand to no more than three cards". `overHandLimit` + // (state.ts) is the one copy of that test; the Frame and the page ask the same function. + return overHandLimit(s, player) ? 'HAND_LIMIT' : null; } // -- Freight Agent -------------------------------------------------------- diff --git a/src/engine/state.ts b/src/engine/state.ts index e23c5f0..ed2c873 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -17,7 +17,7 @@ import type { OfficeTier, TrackGeometry, } from './content.ts'; -import { MAX_CONSIST, officeProfile } from './content.ts'; +import { HAND_LIMIT, MAX_CONSIST, officeProfile } from './content.ts'; // --------------------------------------------------------------------------- // Identifiers @@ -1040,6 +1040,19 @@ export function turnOf(s: GameState, player: PlayerIndex): TurnState { return t; } +/** + * §6.2 — IS THIS PLAYER HOLDING MORE THAN THEY MAY? Three cards, or four while a Red Flag is held. + * + * ONE answer, because there were three of them: `check('draw.end')` refused on it, `snapshot()` + * recomputed it inline for the Frame, and `web/game.ts` kept a third for the page. All three agreed + * — which is the state a disagreement starts from, and #96 is what that costs when the two halves + * are a screen and the server that refuses what the screen offered. + */ +export function overHandLimit(s: GameState, player: PlayerIndex): boolean { + const hand = s.decks.hands.get(player) ?? []; + return hand.length > (s.decks.redFlags.get(player) ? HAND_LIMIT + 1 : HAND_LIMIT); +} + export function freshTurn(moves: number): TurnState { return { option: null, diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index 6b596eb..f18b543 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -15,7 +15,7 @@ * panel cannot drift from the rules. */ -import { MAX_CONSIST } from '../engine/content.ts'; +import { MAX_CONSIST, crewTrayCount } from '../engine/content.ts'; import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts'; import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts'; import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, freightRuleSpentHere, isFreight, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts'; @@ -868,13 +868,69 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[] } } - // Trains held for want of a Crew Tray (§7) — the scarcity mechanic, made visible. - const due = s.timetable[s.clock.stage - 1]; - if (due !== null && due !== undefined && s.freeTrays.length === 0) { + /** + * TRAINS HELD FOR WANT OF A CREW TRAY (§7) — the scarcity mechanic, made visible (#98). + * + * This covered the TIMETABLED train due out this Stage and nothing else, which meant the two + * other things that queue for the same pool reported nothing at all. A player who spent a card on + * an Extra, or ordered a second section, got an EMPTY panel while their train sat behind an + * exhausted pool — and each had been announced once in the log in a line that promised a future + * event ("as soon as a Crew Tray frees up") which nothing then confirmed. + * + * All three are one condition, so they are written as one block: no free tray, and something + * waiting for one. The count rides along because "no free Crew Tray" reads like a permanent fact + * about the game rather than a state that will pass. + */ + // `crewTrayCount` is the pool's size, asked rather than re-derived. `trays.size + freeTrays.length` + // gives the same number in play — `retireTrain` moves a tray back — but it is a second way to know + // one fact, which is the shape of every bug this release fixed. + const trays = `${s.freeTrays.length} of ${crewTrayCount(s.players.length)} Crew Trays free`; + if (s.freeTrays.length === 0) { + const due = s.timetable[s.clock.stage - 1]; + if (due !== null && due !== undefined) { + out.push({ + where: `Train ${due}`, + why: `due to depart but HELD — no free Crew Tray (${trays})`, + severity: 'stuck', + }); + } + + // An Extra belongs to the player who played the card (§7), so it is their errand and it is + // reported to them. A second section is the table's, like any Timetabled train. + for (const x of s.pendingExtras) { + if (x.player !== player) continue; + out.push({ + where: `Extra X${x.trainNumber}`, + why: `played and waiting to be made up — no free Crew Tray (${trays})`, + severity: 'stuck', + }); + } + + for (const n of s.pendingSecondSections) { + out.push({ + where: `Train ${n}`, + why: `second section ordered and waiting to be made up — no free Crew Tray (${trays})`, + severity: 'stuck', + }); + } + } + + /** + * A TRAIN HELD AT THE LIMITS BY AN INTERLOCKING (#99). + * + * It is inside the player's Limits, not on an A/D track, and it takes the first track that frees + * ahead of any newcomer. The map now draws it on the Limits square; this says what it is waiting + * for, which is the Office emptying rather than anything the held train itself can do. + */ + for (const id of area.heldAtLimits) { + const t = s.trays.get(id); + if (!t) continue; out.push({ - where: `Train ${due}`, - why: 'due to depart but HELD — no free Crew Tray', - severity: 'stuck', + where: `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber ?? '—'}`, + why: + 'held at your Limits by the Interlocking instead of colliding — it takes the first A/D ' + + 'track that frees, ahead of any train arriving after it', + severity: 'waiting', }); } diff --git a/src/sim/view.ts b/src/sim/view.ts index 6a16b46..604d6fd 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -10,7 +10,7 @@ * drift into two different pictures of the same board. */ -import { regionOfTransit } from '../engine/advance.ts'; +import { isExpedited, regionOfTransit } from '../engine/advance.ts'; import { areaAtSeat, areaOf, @@ -35,6 +35,7 @@ import { REALIGNMENTS, OFFICE_ORDER, SPACE_USE_CARDS, + crewTrayCount, enhancementRule, enhancementText, industryProfile, @@ -47,7 +48,7 @@ import { } from '../engine/content.ts'; import type { Intent } from '../engine/intents.ts'; import type { Facility, GameConfig, GameState, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts'; -import { actingPlayer, carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts'; +import { actingPlayer, carsOn, overHandLimit, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts'; import type { Direction, Hand, HouseRules, TrackGeometry } from '../engine/content.ts'; import type { Port } from '../engine/track.ts'; import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts'; @@ -104,6 +105,15 @@ export type CellView = { * standing in front of them — the card is face down in a box somewhere by then. */ what: string; + /** + * HELD AT THE LIMITS BY AN INTERLOCKING, rather than standing on this square (#99). + * + * The engine keeps these in `OfficeArea.heldAtLimits` and deliberately does NOT move + * `tray.position` onto the grid — a held train is not on a square anything may switch it from. + * So the map has to draw it from the held list, and mark it, or it reads as an ordinary arrival + * the player could work. + */ + heldAtLimits?: true; }[]; /** * Office card only: how many A/D tracks the tier has — null everywhere else. @@ -457,7 +467,17 @@ export type Frame = { * 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). */ + /** + * The viewer's position in the west-to-east chain, which is not their player index (§4.4). + * + * CARRIED AHEAD OF ITS CALLER, DELIBERATELY (#45). Nothing renders this today — the 2026-08-30 + * dead-field audit found it read only by one test, and Jesse deferred the delete-or-document + * call. Documenting rather than deleting, because Gitea#20's common board keys every district by + * SEAT and resolves the player through `playerAtSeat` (§Employee Rotation moves players between + * districts), so a client that must pick its own district out of a seat-keyed board needs exactly + * this and cannot derive it from `viewer`. If step 2 ships without using it, delete it then — + * this note is the reason it survived one audit, not a permanent exemption from the next. + */ viewerSeat: number; /** * §4.4's opening D12 per player, and the roll that chose the Superintendent — kept so a client @@ -750,6 +770,39 @@ function baseOf( */ function trainsOnCard(s: GameState, viewerSeat: SeatIndex, key: string): CellView['trains'] { const out: CellView['trains'] = []; + + /** + * TRAINS HELD AT THE LIMITS — drawn here or drawn nowhere (#99). + * + * `arriveAtOffice` takes the tray out of the Mainline node's `transits` and, when an Interlocking + * saves it from Gap 2d's collision, pushes it onto `heldAtLimits` without giving it a grid + * position. The map draws mainline nodes from `transits` and squares from `position.at === 'grid'` + * — so between the two the train was drawn in NEITHER, and simply vanished off the board until an + * A/D track freed some Stages later. + * + * At WHICH Limits: the end it came in by. An eastbound train entered from the west, so it is held + * at `limitsWest`; a westbound one at `limitsEast`. + */ + const area = areaAtSeat(s, viewerSeat); + for (const id of area.heldAtLimits) { + const t = s.trays.get(id); + if (!t) continue; + const at = t.direction === 'east' ? area.limitsWest : area.limitsEast; + if (`${at.row},${at.col}` !== key) continue; + out.push({ + trayId: id, + label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`, + cars: t.consist.map((c) => carLabel(c, viewerSeat)), + engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)), + facing: railFacingOf(t), + what: + 'HELD AT THE LIMITS — the Interlocking stopped it on the Limit Track instead of letting it ' + + 'collide with a full Office. It takes the first A/D track that frees, ahead of any train ' + + `arriving after it. ${trainRules(t)}`, + heldAtLimits: true, + }); + } + for (const [id, t] of s.trays) { if (t.position.at !== 'grid' || t.position.seat !== viewerSeat) continue; if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue; @@ -1515,6 +1568,27 @@ export function projectSharedTable(s: GameState) { ? `Mainline card ${t.position.index}` : `Office Area (${t.position.coord.col},${t.position.coord.row})`, })), + /** + * THE CREW TRAY POOL, WHICH IS §7's SCARCITY MECHANIC (#98). + * + * `state.ts` calls it explicit, and it was explicit only in the engine: there are fewer trays + * than there are trains wanting one, and nothing said how many were left. Public without + * question — the trays are physical objects in the middle of the table, and this is a count + * beside the deck and yard counts already here. + */ + crewTrays: { free: s.freeTrays.length, total: crewTrayCount(s.players.length) }, + /** + * THE TRAINS QUEUED FOR ONE — the other half, and the half that had been promised in words. + * + * Playing an Extra says "it runs once as soon as a Crew Tray frees up"; ordering a second + * section says "an identical train will run right behind it". Both were announced once in the + * log and then existed only in the engine, so neither promise was ever visibly kept. Who played + * an Extra is public: §7 gives the train to the player who played the card, in the open. + */ + queued: { + extras: s.pendingExtras.map((x) => ({ trainNumber: x.trainNumber, player: x.player })), + secondSections: [...s.pendingSecondSections], + }, }; } @@ -1647,8 +1721,7 @@ export function snapshot( viewer, viewerSeat, handCount: (s.decks.hands.get(viewer) ?? []).length, - overHandLimit: - (s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT), + overHandLimit: overHandLimit(s, viewer), objective: objectiveOf(s, viewer), runningRow, limits, @@ -1825,6 +1898,12 @@ export function cardDescription(s: GameState, id: string): string { export function trainRules(t: { trainNumber: number | null; trainIsExtra: boolean; + /** + * X17 only — whether the speeches are made, which is what decides which HALF of its printed rule + * the train is currently living under (#100). Optional because the timetable renders a train + * number with no tray behind it; absent means "not yet", which is the state a train starts in. + */ + speechMade?: boolean; }): string { const p = trainProfile(t.trainNumber ?? 0, t.trainIsExtra); if (!p) return ''; @@ -1869,11 +1948,25 @@ export function trainRules(t: { if (p.rules.pickUpEmptiesOnly) { parts.push('EMPTIES ONLY — it may not couple a loaded car. A caboose is not a load.'); } + /** + * WHICH HALF OF ITS RULE THE CAMPAIGN TRAIN IS IN (#100). + * + * "One turn at station (speeches) then expedite" is two states, not one sentence. This used to + * print the sentence and stop, so the chip read identically before and after the speeches — while + * the fault the second half creates was warned about only under `expedite`, i.e. to every train + * EXCEPT the one that had just become subject to it. + */ if (p.rules.stopThenExpedite) { - parts.push('STOPS ONCE FOR SPEECHES, then runs expedited from its next Office onward'); + parts.push( + t.speechMade + ? 'SPEECHES MADE — it runs EXPEDITED from here on' + : 'STOPS ONCE FOR SPEECHES at its first Office, then runs expedited from the next one onward', + ); } - if (p.rules.expedite) { + // `isExpedited` (advance.ts) is the engine's own test, borrowed rather than restated: a card that + // described a rule the engine did not apply — or the reverse — is the whole failure this is in. + if (isExpedited(t)) { // It is released and switched exactly like any other train — the restriction is on where it may // be LEFT, not on when it leaves. parts.push( diff --git a/src/web/game.ts b/src/web/game.ts index 23820b9..04cff20 100644 --- a/src/web/game.ts +++ b/src/web/game.ts @@ -31,6 +31,7 @@ import { legalActions } from '../engine/legal.ts'; import { createGame } from '../engine/setup.ts'; import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts'; import { actingPlayer } from '../engine/state.ts'; +import { overHandLimit as overHandLimitOf } from '../engine/state.ts'; import { playerAtSeat } from '../engine/state.ts'; import { cuesFor, narrate } from '../sim/narrate.ts'; // Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv, @@ -246,7 +247,6 @@ export type Game = { * had already been played and the hand held three or fewer. Derived from the hand each render, so * it cannot drift out of step with what is actually held. */ - mustPlayCard: boolean; /** * Sounds the last batch of events earned, for the page to play and clear. * @@ -313,7 +313,7 @@ export const SOLO_PLAYER = 'Solitaire'; export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game { const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] }); - const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null }; + const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null }; // A history that opens mid-Stage reads as though something was missed. Say what the game IS // first, then let the clock take over. game.log.push({ text: 'Game Begins', tone: 'start' }); @@ -332,7 +332,7 @@ export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game { */ export function newMultiplayerGame(seed: number, config: GameConfig, playerNames: string[]): Game { const state = createGame({ id: `mp-${seed}`, seed, config, playerNames }); - const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null }; + const game: Game = { state, seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null }; game.log.push({ text: 'Game Begins', tone: 'start' }); /** * NO SEED AT A TABLE WITH MORE THAN ONE SEAT (Gitea#20 step 1). @@ -1060,9 +1060,7 @@ function makeUpAdvice( * button with a reason instead of hiding a move that has simply become illegal. */ export function overHandLimit(game: Game, seat: PlayerIndex = 0): boolean { - const hand = game.state.decks.hands.get(seat) ?? []; - const limit = game.state.decks.redFlags.get(seat) ? HAND_LIMIT + 1 : HAND_LIMIT; - return hand.length > limit; + return overHandLimitOf(game.state, seat); } /** @@ -1110,7 +1108,6 @@ export function submit(game: Game, intent: Intent, as: PlayerIndex | null = null } game.history.push(intent); record(game, result.events, actor); - game.mustPlayCard = overHandLimit(game); drain(game); return true; } diff --git a/src/web/main.ts b/src/web/main.ts index ba4498b..632b0d7 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -1957,12 +1957,23 @@ function renderActions( } html += ``; } - if ( - f.phaseKey === 'localOps' && - f.option === 'draw' && - !menu.options.some((i) => i.type === 'draw.end') - ) { - // The hand being counted is the ACTOR's — they are the one who cannot end the turn. + /** + * WHY "END LOCAL OPERATIONS" IS NOT THERE (#45). + * + * This asked the question backwards: "the engine is offering no `draw.end`, so it must be the + * hand limit." That was true only because `check('draw.end')` happens to refuse for exactly three + * reasons and the two guards above rule out the other two — a fourth reason would have made this + * block explain a refusal by describing something else entirely, which is #90 verbatim. + * + * `f.overHandLimit` IS the fact, and the Frame has carried it all along for precisely this: "the + * same test the engine applies to `draw.end`, asked here so the page can disable the button with a + * reason instead of hiding a move that has simply become illegal" (`web/game.ts`). It was computed, + * serialised and sent to nobody. Behaviour is unchanged today; what changes is that the screen now + * states the reason it is giving rather than inferring it from an absence. + */ + if (f.phaseKey === 'localOps' && f.option === 'draw' && f.overHandLimit) { + // The hand being counted is the VIEWER's, like `f.option` and `f.handCount` beside it — and the + // viewer is the actor whenever this menu is on screen at all. const hand = f.handCount; /** * WHEN NOTHING IN HAND MAY BE DISCARDED, SAY SO AND SAY WHAT TO DO INSTEAD. diff --git a/src/web/session.ts b/src/web/session.ts index 4c870d0..6723d5d 100644 --- a/src/web/session.ts +++ b/src/web/session.ts @@ -29,7 +29,6 @@ import { handPlayable, isOutOfTurn, newGame, - overHandLimit, submit, toSave, undo, @@ -58,8 +57,6 @@ export type Session = { seat(): PlayerIndex; /** Whose turn it is, or null when the game is over or waiting on nothing. */ actor(): PlayerIndex | null; - /** True when the hand is over §6.2's limit and the turn cannot be ended. */ - overHandLimit(): boolean; /** Which cards in hand are playable right now, in hand order. */ handPlayable(): boolean[]; /** @@ -161,7 +158,6 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca menu: () => actionMenu(game), seat: () => 0, actor: () => currentActor(game), - overHandLimit: () => overHandLimit(game), handPlayable: () => handPlayable(game), submit: async (intent: Intent) => { // Seat 0 is the solitaire player, and the extension vote (Gitea#11) is the one intent that @@ -341,7 +337,6 @@ export function createRemoteSession( menu: () => menu ?? { options: [], direct: [], placeable: [], hand: [], makeUp: null }, seat: () => seat, actor: () => need().actor, - overHandLimit: () => need().overHandLimit, handPlayable: () => (menu?.hand ?? []).map((h) => h.playNow !== null), async submit(intent: Intent): Promise { const seq = nextSeq++; diff --git a/test/display-gaps.test.ts b/test/display-gaps.test.ts new file mode 100644 index 0000000..7cb798a --- /dev/null +++ b/test/display-gaps.test.ts @@ -0,0 +1,258 @@ +/** + * WHAT THE ENGINE KNOWS AND THE SCREEN DOES NOT — the 2026-09-07 sweep. + * + * Gitea#21, Gitea#22, #94 and #96 were four instances of one fault in a row: the engine gained a + * thing that changes what a train may do, and nothing drew it. Each was found by a player hitting + * it. So rather than wait for the fifth, every field of `GameState` and its nested types was + * enumerated and checked for a reader in `sim/view.ts`, `src/web/` and `sim/narrate.ts`, and the + * survivors were then verified by running the engine rather than by trusting the grep. + * + * Four fields had no reader anywhere. `movedThisPhase` is set and cleared inside one `advance` call + * and is genuinely nobody's business. The other three are these tests. The bookkeeping fields whose + * EFFECT is already visible as legality — `freightWorked`, `drawnThisTurn`, `freightAgentUsed`, + * `movesUsed` (its complement `movesRemaining` is on the Frame), `switchedSince` — are deliberately + * not here: a field is not a display gap merely because no one renders it. + * + * The method is worth more than the three fixes, and is written down in TODO Reference · #98. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createGame } from '../src/engine/setup.ts'; +import { crewTrayCount } from '../src/engine/content.ts'; +import { areaOf } from '../src/engine/apply.ts'; +import { impediments } from '../src/sim/narrate.ts'; +import { projectDistrict, projectSharedTable, publicSnapshot, trainRules } from '../src/sim/view.ts'; +import type { CrewTray, GameConfig, GameState, PlayerIndex, SeatIndex } from '../src/engine/state.ts'; +import { seatOf } from '../src/engine/state.ts'; + +const config: GameConfig = { + mode: 'competitive', + days: 3, + minCombinedRevenue: 0, + maxCollisionsPerDay: 0, + maxCollisionsTotal: 0, + pvpCardsAllowed: false, + optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false }, +}; + +const game = (): GameState => { + const s = createGame({ id: 'g', seed: 4242, config, playerNames: ['Ann', 'Bob'] }); + s.status = 'active'; + return s; +}; + +/** + * THE CREW TRAY POOL IS A MECHANIC, AND IT WAS INVISIBLE (#98). + * + * `state.ts` calls tray scarcity "an explicit mechanic": there are fewer trays than there are trains + * wanting one, and which trains get held is the whole of §7. The engine knows three things about it + * — how many trays are free, which Extras are queued for one, and which second sections are — and + * the view read none of them. + * + * The panel that answers "why is nothing moving?" had exactly one tray rule, keyed off the train due + * out THIS Stage (`s.timetable[stage - 1]`). So a player who spent a card on an Extra, or ordered a + * second section, got a blocked panel that was completely EMPTY while their train sat behind an + * exhausted pool — and both had been announced once in the log, in a line that promised a future + * event ("as soon as a Crew Tray frees up") which nothing then confirmed. + */ +describe('the Crew Tray pool is a mechanic the player can see (#98)', () => { + /** A table whose trays are all out, with one Extra and one second section queued behind them. */ + const jammed = (): GameState => { + const s = game(); + s.freeTrays = []; + s.pendingExtras.push({ trainNumber: 17, player: 0 as PlayerIndex }); + s.pendingSecondSections.push(8); + return s; + }; + + it('reports how many Crew Trays are free, and how many there are', () => { + const s = game(); + const total = crewTrayCount(2); + assert.equal(s.freeTrays.length, total, 'the premise is gone: trays were already out at setup'); + assert.deepEqual(projectSharedTable(s).crewTrays, { free: total, total }); + + s.freeTrays = s.freeTrays.slice(0, 1); + assert.deepEqual( + projectSharedTable(s).crewTrays, + { free: 1, total }, + 'the pool emptied and the shared table did not notice', + ); + }); + + it('names the trains queued for a tray, so a promise made in the log is kept on the board', () => { + const q = projectSharedTable(jammed()).queued; + assert.deepEqual(q.extras, [{ trainNumber: 17, player: 0 }], 'a played Extra is waiting nowhere visible'); + assert.deepEqual(q.secondSections, [8], 'an ordered second section is waiting nowhere visible'); + }); + + it('tells the player who played the Extra that it is held for want of a crew', () => { + const blocked = impediments(jammed(), 0 as PlayerIndex); + const extra = blocked.find((b) => /X17/.test(b.where)); + assert.ok(extra, 'the blocked panel said nothing about an Extra held for want of a Crew Tray'); + assert.match(extra.why, /Crew Tray/, 'it was listed without naming the thing it is waiting for'); + assert.equal(extra.severity, 'stuck'); + // The POOL SIZE, not a second derivation of it. Written as `trays.size + freeTrays.length` + // first, which reads 0 of 0 for any state where a tray is neither free nor carrying a train. + assert.match( + extra.why, + new RegExp(`0 of ${crewTrayCount(2)} Crew Trays free`), + 'the panel reported the wrong pool size', + ); + }); + + it('says the same for a second section, which waits on the identical pool', () => { + const blocked = impediments(jammed(), 0 as PlayerIndex); + const second = blocked.find((b) => /Train 8\b/.test(b.where) && /second section/i.test(b.why)); + assert.ok(second, 'an ordered second section was queued invisibly'); + assert.match(second.why, /Crew Tray/); + }); + + it('says none of it once a tray is free, because then nothing is being waited on', () => { + const s = jammed(); + s.freeTrays = ['tray0']; + const blocked = impediments(s, 0 as PlayerIndex); + assert.equal( + blocked.filter((b) => /Crew Tray/.test(b.why)).length, + 0, + 'a free tray still reported trains held for want of one', + ); + }); + + it('is on the common board too — the pool is on the table, not in a hand', () => { + const pub = publicSnapshot(jammed()) as unknown as Record; + assert.ok('crewTrays' in pub, 'a spectator cannot see the scarcity everyone at the table can'); + assert.ok('queued' in pub); + }); +}); + +/** + * A TRAIN HELD AT THE LIMITS IS STILL ON THE BOARD (#99). + * + * The Interlocking enhancement is the designed answer to a full Office: instead of the automatic + * collision of Gap 2d, "may stop an inbound train on the Limit Track" — the train is held inside the + * player's Limits, and takes the first A/D track that frees, ahead of any newcomer. + * + * It was drawn NOWHERE. `arriveAtOffice` removes the tray from the Mainline node's `transits` + * (advance.ts) and the Interlocking branch pushes it onto `area.heldAtLimits` without assigning + * `tray.position` — so the map, which draws mainline nodes from `transits` and district squares from + * `position.at === 'grid'`, has nothing to draw it from in either place. The train vanished off the + * board on arrival and reappeared in the Office some Stages later. + * + * Measured before fixing: with the tray in `transits` the Interchange node carries its chip; with + * the tray moved to `heldAtLimits` exactly as the engine moves it, the node's `trains` is empty and + * no grid square has gained it. + * + * The fix is in the VIEW, not the engine. The engine's state is right — a held train is inside the + * Limits and not on an A/D track, which is what `heldAtLimits` says — and `position` is left alone + * deliberately, so nothing may treat the train as standing on a square it could be switched from. + */ +describe('a train held at the Limits is drawn at the Limits (#99)', () => { + const held = (direction: 'east' | 'west') => { + const s = game(); + const seat = seatOf(s, 0 as PlayerIndex); + const area = areaOf(s, 0 as PlayerIndex); + const trayId = s.freeTrays.pop()!; + const tray: CrewTray = { + id: trayId, + trainNumber: 5, + trainIsExtra: false, + engineAt: 0, + consist: [], + direction, + movesUsed: 0, + position: { at: 'mainline', index: 1 }, + } as CrewTray; + s.trays.set(trayId, tray); + area.heldAtLimits.push(trayId); + return { s, seat, trayId, area }; + }; + + it('draws it on the Limits square it is standing at, not nowhere', () => { + const { s, seat, trayId, area } = held('east'); + const { cells } = projectDistrict(s, seat as SeatIndex); + const on = cells.filter((c) => c.trains.some((t) => t.trayId === trayId)); + assert.equal(on.length, 1, 'a train held at the Limits is drawn on no square at all'); + assert.equal(on[0]!.row, area.limitsWest.row, 'drawn at the wrong Limits'); + assert.equal(on[0]!.col, area.limitsWest.col); + }); + + it('holds an EASTBOUND train at the western Limits, because that is the end it came in by', () => { + const { s, seat, area } = held('east'); + const { cells } = projectDistrict(s, seat as SeatIndex); + const on = cells.find((c) => c.trains.length > 0)!; + assert.deepEqual({ row: on.row, col: on.col }, { row: area.limitsWest.row, col: area.limitsWest.col }); + }); + + it('and a WESTBOUND train at the eastern Limits', () => { + const { s, seat, area } = held('west'); + const { cells } = projectDistrict(s, seat as SeatIndex); + const on = cells.find((c) => c.trains.length > 0)!; + assert.deepEqual({ row: on.row, col: on.col }, { row: area.limitsEast.row, col: area.limitsEast.col }); + }); + + it('says on the chip that it is HELD, so it is not read as a train free to switch', () => { + const { s, seat, trayId } = held('east'); + const { cells } = projectDistrict(s, seat as SeatIndex); + const chip = cells.flatMap((c) => c.trains).find((t) => t.trayId === trayId)!; + assert.equal(chip.heldAtLimits, true, 'a held train looked exactly like one standing on the square'); + assert.match(chip.what, /Interlocking|held/i, 'the chip does not say why it is standing there'); + }); + + it('tells the district owner it is waiting, and what for', () => { + const { s } = held('east'); + const blocked = impediments(s, 0 as PlayerIndex); + const b = blocked.find((x) => /Train 5/.test(x.where) && /Limits/i.test(x.why)); + assert.ok(b, 'the blocked panel said nothing about a train held at the Limits'); + }); + + it('does not invent a train on a square when nothing is held', () => { + const s = game(); + const { cells } = projectDistrict(s, seatOf(s, 0 as PlayerIndex) as SeatIndex); + assert.equal(cells.flatMap((c) => c.trains).length, 0, 'a district with no trains drew one'); + }); +}); + +/** + * THE CAMPAIGN TRAIN'S SPEECHES CHANGE ITS RULES, AND THE CARD HAS TO SAY WHICH HALF IT IS IN (#100). + * + * X17 is "one turn at station (speeches) then expedite". Its first Office arrival is an ordinary + * stop; every arrival after that runs EXPEDITED, which means that if it is not back on the Office + * square when the next Mainline Phase begins it is a Station Master fault costing 1 Revenue. + * + * `trainRules()` took `{ trainNumber, trainIsExtra }` — it could not see `speechMade` even though + * both of its tray-side callers pass a whole `CrewTray` that has it. So the chip read identically + * before and after, and worse: the "EXPEDITED — must be kept ready to highball… costs 1 Revenue" + * warning is printed only under `rules.expedite`, so X17 became subject to a fault whose warning the + * game shows to other trains and never to it. + */ +describe('the Campaign Train says whether its speeches are made (#100)', () => { + const X17 = { trainNumber: 17, trainIsExtra: true } as const; + + it('before the speeches, says they are still to come and does not claim it is expedited yet', () => { + const t = trainRules({ ...X17 }); + assert.match(t, /SPEECHES/i); + assert.doesNotMatch(t, /costs 1 Revenue/, 'it warned of a fault the train is not yet subject to'); + }); + + it('after the speeches, says it is expedited NOW and carries the fault it is now subject to', () => { + const t = trainRules({ ...X17, speechMade: true }); + assert.match(t, /EXPEDITED/, 'a train that is now expedited did not say so'); + assert.match(t, /costs 1 Revenue/, 'the fault warning is shown to other trains and not to this one'); + }); + + it('reads differently before and after — the whole of the bug was that it did not', () => { + assert.notEqual(trainRules({ ...X17 }), trainRules({ ...X17, speechMade: true })); + }); + + it('still warns a permanently expedited train, which must not regress', () => { + const fast = trainRules({ trainNumber: 5, trainIsExtra: false }); + assert.match(fast, /EXPEDITED/); + assert.match(fast, /costs 1 Revenue/); + }); + + it('says nothing about speeches for a train that has no such rule', () => { + assert.doesNotMatch(trainRules({ trainNumber: 5, trainIsExtra: false }), /SPEECHES/i); + }); +}); diff --git a/test/multiplayer.test.ts b/test/multiplayer.test.ts index c5534f4..c48428f 100644 --- a/test/multiplayer.test.ts +++ b/test/multiplayer.test.ts @@ -29,7 +29,7 @@ import type { Game } from '../src/web/game.ts'; /** The thin wrapper `actionMenu` expects, built directly around an already-created multi-player state * — `newGame` (game.ts) hardcodes one player, so it cannot construct this for a multi-seat game. */ const wrap = (s: GameState): Game => - ({ state: s, seed: s.seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null }); + ({ state: s, seed: s.seed, history: [], log: [], cues: [], scheduled: null, justDrawn: null, announced: null }); const competitive: GameConfig = { mode: 'competitive', diff --git a/test/redaction.test.ts b/test/redaction.test.ts index 5785bc1..391e4fc 100644 --- a/test/redaction.test.ts +++ b/test/redaction.test.ts @@ -443,6 +443,10 @@ describe('#91 — nothing private survives serialisation, in any state', () => { 'openingRolls', // Where every train is standing. 'trains', + // The Crew Tray pool and the trains queued for one (#98). §7 scarcity is played out in the + // open: the trays are objects in the middle of the table, and an Extra is played face up, so + // who is waiting for a crew is not a secret. Counts and train numbers only — never a hand. + 'crewTrays', 'queued', // The board itself. 'division', 'districts', ]; diff --git a/test/session.test.ts b/test/session.test.ts index 169bcae..161fcf1 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -17,7 +17,7 @@ import assert from 'node:assert/strict'; 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 { actionGroups, currentActor, handPlayable, newGame, submit, toSave, view } from '../src/web/game.ts'; import { createLocalSession } from '../src/web/session.ts'; import { seatLabel } from '../src/sim/view.ts'; @@ -81,7 +81,6 @@ describe('a local session plays the same game as the calls it replaced', () => { assert.equal(session.seat(), 0); assert.equal(session.actor(), currentActor(session.game)); assert.deepEqual(session.handPlayable(), handPlayable(session.game)); - assert.equal(session.overHandLimit(), overHandLimit(session.game)); }); });