diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d6dde7..685d789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,69 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.7.9.7 — 2026-09-07 + +The last item off 0.7.9.6's sweep — the one parked as a maybe, which turned out to have a second half +worth more than the first. + +### A dispatch device now says whether it is still available (#101) + +Telegraph (+4), Telephone (+8) and Radio (+12) are "once a day, when dispatching facing trains, add ++N to the other train's number". `enhancementText(key)` takes only the key, so the tooltip could not +vary with anything — a spent Radio read "Once a day, add +12…" for the rest of the Day, advertising a +bonus that was not there. `trainRules` before #100, in another corner of the same view. + +**The half that actually surprises.** `spendDispatchBonus` reads `areaOf(s, s.clock.superintendent)` +— the **Superintendent's own** devices, not the train owner's — and the Fedora moves every +`STAGES_PER_SHIFT` (3) Stages, four times a Day. So a player's Radio does nothing at all for +three-quarters of the Day, and is spent automatically, without its owner being asked, during the +quarter it is theirs to use. Neither half was anywhere on the board. + +The card now reads as one of three states — available and dispatching, unspent but idle while +somebody else holds the Fedora, or spent until the next Day — and names the shift length, because +"not now" without "for how long" is half an answer. A spent device is struck through on the board. + +**On every district, not only your own** (Jesse's call): it is public, and a rival's spent Radio is +exactly what you want to know before forcing a meet. `projectDistrict` serves the player's own cells +and the common board's districts alike, so one change covers both. + +What counts as a device is `enhancementRule(key)?.dispatchBonus`, not three keys written out in the +view — the ladder lives in `ENHANCEMENT_RULES`, and a fourth rung would otherwise be silently exempt. + +### A stale comment corrected, and turned into a test + +`advance.ts` warned that indexing a seat-keyed Office Area with the player holding the Fedora "is +right only while seating is the identity map". It reads as a live Employee Rotation bug and is not +one: `areaOf(s, p)` **is** `areaAtSeat(s, seatOf(s, p))`. A comment that sends the next reader +chasing a bug that does not exist costs about what the bug would. It is rewritten, and the claim is +now pinned by a test — seating set to a real permutation, and the Superintendent's own district, not +the seat with the same index, is the one that reads as dispatching. + +### Two things caught by mutation rather than by passing + +**A test that passed for the wrong reason.** "Leaves a non-dispatch enhancement alone" asserted the +absence of /spent|Fedora/ with the Fedora held — and a mutant with the `dispatchBonus` guard deleted +**passed it**, because the leaked text in that case reads "Available today, and this district is +dispatching", which contains neither word. A test that something was left alone has to compare it +against what it should be; it asserts equality with `enhancementText` now, in both Fedora states. + +**The replay wire format needed the field.** Cells are packed positionally and the round-trip test +caught the loss immediately. The flag is index 9 and reads `?? []`, the same tolerance `standingWest` +uses, so recordings made before it existed report no device spent — exactly what they drew at the +time, leaving every published replay unchanged. + +### Proof + +943 tests pass, up from 934. The 9 new ones were written red. Mutation: using the raw player index +for the Fedora check fails 1, ignoring the spent record fails 3, and treating every enhancement as a +dispatch device fails 1 — that last one only after the test above was tightened, which is how the +hole was found. + +**Not verified at a table**, like 0.7.9.6. #39 and #35 still stand, and the pre-0.8.0 session is +where they get closed. + +--- + ## 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 diff --git a/TODO.md b/TODO.md index 3903c57..a79f498 100644 --- a/TODO.md +++ b/TODO.md @@ -1880,8 +1880,8 @@ the numbers stay so cross-references above and below still resolve. **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. + already on the Frame (`movesRemaining`). `dispatchUsedToday` was left as the one genuine maybe, + and was then **done as #101** — it turned out to have a second half worth more than the first. 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 @@ -1922,6 +1922,54 @@ the numbers stay so cross-references above and below still resolve. 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. +101. ~~**A dispatch device never said it had been spent — or that it was somebody else's to + spend.**~~ — done 2026-09-07 in v0.7.9.7, the last item off #98's sweep and the only one that + had been parked rather than fixed. + + Telegraph (+4), Telephone (+8) and Radio (+12) are "once a day, when dispatching facing trains, + add +N to the other train's number". `enhancementText(key)` takes only the KEY, so the tooltip + could not vary with anything: a spent Radio read "Once a day, add +12…" for the rest of the Day, + advertising a bonus that was not there. That is `trainRules` before #100, in another corner of + the same view. + + **THE SECOND HALF IS WORTH MORE THAN THE FIRST, and is why this stopped being a small item.** + `spendDispatchBonus` reads `areaOf(s, s.clock.superintendent)` — the SUPERINTENDENT's own + devices, not the train owner's — and the Fedora moves every `STAGES_PER_SHIFT` (3) Stages, four + times a Day. So a player's Radio does nothing at all for three-quarters of the Day, and is spent + automatically, without its owner being asked, during the quarter it is theirs. Neither half was + anywhere on the board. The card now says which of the three states it is in, and names the shift + length, because "not now" without "for how long" is half an answer. + + **Shown on EVERY district** (Jesse, 2026-09-07), not only the viewer's: it is public, and a + rival's spent Radio is what you want to know before forcing a meet. One change covers both, as + `projectDistrict` serves the player's own cells and the common board's `districts` alike — note + that a player's Frame carries only their own district, so in practice "every district" is the + common board, which is pre-existing and not touched here. + + What counts as a device is `enhancementRule(key)?.dispatchBonus`, not a list of three keys + written out in the view: the ladder lives in `ENHANCEMENT_RULES`, and a fourth rung added there + would otherwise be silently exempt from the whole of this. + + **A stale comment corrected, and pinned.** `advance.ts` warned that indexing a SEAT-keyed area + with the PLAYER holding the Fedora "is right only while seating is the identity map". It read as + a live Employee Rotation bug and was not one — `areaOf(s, p)` IS `areaAtSeat(s, seatOf(s, p))`. + A comment that sends the next reader chasing a bug that does not exist costs about as much as + the bug would, so it is rewritten, and the claim is now a test: seating set to a real + permutation, and the Superintendent's own district — not the seat with the same index — is the + one that reads as dispatching. + + **And a test that passed for the wrong reason, caught by mutation.** "Leaves a non-dispatch + enhancement alone" originally asserted the ABSENCE of /spent|Fedora/ with the Fedora held — + and a mutant with the `dispatchBonus` guard deleted passed it, because the leaked text in that + case says "Available today, and this district is dispatching", which contains neither word. A + test that something was left alone has to compare it against what it should be; it asserts + equality with `enhancementText` now, in both Fedora states. + + **The replay wire format needed the field too.** Cells are packed positionally, so the new flag + is index 9 and reads `?? []` — the same tolerance `standingWest` uses. Recordings made before + it existed report no device spent, which is exactly what they drew at the time, so every + published replay is unchanged. + 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 4962603..bfd9fb5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.7.9.6", + "version": "0.7.9.7", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/engine/advance.ts b/src/engine/advance.ts index 403b26b..dfa3125 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -688,8 +688,16 @@ function spendDispatchBonus( const mine = tray.trainNumber ?? 99; const theirs = other.trainNumber ?? 99; - // The Fedora is held by a PLAYER, and the dispatch devices are installed in an Office Area, which - // is keyed by SEAT. Indexing one with the other is right only while seating is the identity map. + /** + * The Fedora is held by a PLAYER; the devices are installed in an Office Area, which is keyed by + * SEAT. `areaOf` is what reconciles the two — it resolves through `seatOf` — so this is correct + * under Employee Rotation and not only while seating happens to be the identity map. + * + * This comment used to say the opposite, warning that indexing one with the other was safe only + * while seating was identity. It read as a live bug and was not one: `areaOf(s, player)` IS + * `areaAtSeat(s, seatOf(s, player))`. Pinned by test rather than asserted here — see #101's + * "spends the Superintendent's own devices under non-identity seating". + */ const area = areaOf(s, s.clock.superintendent); // Best device first — Radio (+12) beats Telephone (+8) beats Telegraph (+4). diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts index 1e6803d..8d5f1cc 100644 --- a/src/sim/board-svg.ts +++ b/src/sim/board-svg.ts @@ -1098,9 +1098,18 @@ export function officeSvg( // card, the old y = H-46 baseline printed the label straight along the rail itself. // Hover text per enhancement, from the card catalogue — an Interlocking used to be a bare word // on the card with nothing to say what it did, or that it does not do it yet. + // A SPENT dispatch device is struck through (#101). Telegraph/Telephone/Radio are "once a + // day", and the label said the same thing before and after the Superintendent spent one — so + // the card advertised a +12 that was not there. Per-`tspan` rather than per-`text` so the + // names still read as one row, and `?? []` because `piecePreview` builds a cell literal by + // hand and has no flags. + const spentFlags = cell.enhancementsSpent ?? []; + const names = cell.enhancements + .map((n, i) => (spentFlags[i] ? `${esc(n)}` : esc(n))) + .join(' · '); out += `${esc(cell.enhancements.join(' · '))}`; + )}">${names}`; } if (selectedTrain) { /** @@ -1336,6 +1345,7 @@ export const BOARD_CSS = ` .bs-mod{font:10px ui-monospace,monospace} text.bs-mod{fill:#c8a04a} .bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace} +.bs-enh-spent{fill:#5b6b7d;text-decoration:line-through} .bs-rowlab{fill:#5f6b7a;font:600 9px ui-monospace,monospace;letter-spacing:.1em} /* THE EDGE OF THE DISTRICT. Deliberately quiet — it is a boundary, not an action — and dashed, so it reads as a line on the table rather than as rail. Nothing is laid outside it (§2.1). */ diff --git a/src/sim/replay.ts b/src/sim/replay.ts index 32225d6..636c7b4 100644 --- a/src/sim/replay.ts +++ b/src/sim/replay.ts @@ -230,7 +230,7 @@ export function compress(frames: Frame[]): Packed { const fi = c.facility ? f.facilities.indexOf(c.facility) : -1; // `trains` rides whole rather than being interned: it changes almost every frame, so a table // of them would be as long as the frames are and buy nothing. - return [ci, wi, c.enhancements, c.cars, fi, c.trains, c.adTracks, c.enhancementsWhat, c.standingWest]; + return [ci, wi, c.enhancements, c.cars, fi, c.trains, c.adTracks, c.enhancementsWhat, c.standingWest, c.enhancementsSpent]; }); return { ...f, cells } as unknown as Frame; }); @@ -264,7 +264,7 @@ export function rehydrateCells( facs: unknown[], ): unknown[] { return packed.map((row) => { - const p = row as [number, number, string[], string[], number, unknown, unknown, string[], number]; + const p = row as [number, number, string[], string[], number, unknown, unknown, string[], number, boolean[]]; const c = cards[p[0]] as [number, number, string, string, boolean, string[]]; // Tolerant the same way `standingWest` below is: an array rides through as-is, a lone object // (an older recording's singular `train`) is wrapped into a one-train roster, and null or @@ -284,6 +284,10 @@ export function rehydrateCells( // cut ahead of or behind the engine exactly as the live board does. Absent in older recordings, // which read as 0 — the whole cut east of the engine, which is what they used to draw anyway. standingWest: p[8] ?? 0, + // Which dispatch devices were spent (#101), so a replay strikes a used Radio through exactly + // as the live board does. Absent in recordings made before it existed, which read as no + // device spent — the same thing they drew at the time, so an old replay is unchanged. + enhancementsSpent: p[9] ?? [], }; }); } diff --git a/src/sim/view.ts b/src/sim/view.ts index 604d6fd..737231d 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -35,6 +35,7 @@ import { REALIGNMENTS, OFFICE_ORDER, SPACE_USE_CARDS, + STAGES_PER_SHIFT, crewTrayCount, enhancementRule, enhancementText, @@ -47,7 +48,7 @@ import { mainlineDescription, } 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 type { Facility, GameConfig, GameState, OfficeArea, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } 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'; @@ -74,6 +75,14 @@ export type CellView = { * `toString()`), so it cannot reach the card catalogue itself. */ enhancementsWhat: string[]; + /** + * Which of those enhancements is SPENT for today, in the same order (#101). + * + * Only ever true of a dispatch device — Telegraph, Telephone, Radio — which is "once a day". The + * reason and the Fedora caveat are already written into `enhancementsWhat`; this is the flag the + * board styles from, because `board-svg.ts` imports nothing and cannot work it out for itself. + */ + enhancementsSpent: boolean[]; /** * EVERY TRAIN STANDING HERE, in order, each with the engine in it and which way it points. * @@ -1253,6 +1262,52 @@ export function describeIntent(s: GameState, i: Intent): string { * made it. For a player's own view that seat is theirs; for a spectator's view of district N it is * N, which is the same fact asked from outside. */ +/** + * WHAT AN ENHANCEMENT DOES — AND WHETHER IT CAN DO IT RIGHT NOW (#101). + * + * `enhancementText(key)` takes only the key, so it says the same thing for ever. That is right for + * every enhancement except the three dispatch devices, which are "once a day": a spent Radio read + * "Once a day, add +12…" all Day after it was gone, which is `trainRules` before #100 in a different + * corner of the same view. + * + * AND THE FEDORA, which is the half that actually surprises. `spendDispatchBonus` (advance.ts) reads + * the SUPERINTENDENT's own devices, not the train owner's, and the Fedora moves every + * `STAGES_PER_SHIFT` Stages — so a device does nothing at all while somebody else is dispatching, + * and is spent automatically, without its owner being asked, while they are. + * + * `dispatchBonus` decides what counts as a device, rather than a list of three keys written out + * here: the ladder lives in `ENHANCEMENT_RULES` and a fourth rung would otherwise be silently + * exempt. + */ +function enhancementState( + s: GameState, + area: OfficeArea, + seat: SeatIndex, + key: string, +): { what: string; spent: boolean } { + const base = enhancementText(key) ?? prettyKey(key); + if (enhancementRule(key)?.dispatchBonus === undefined) return { what: base, spent: false }; + + const spent = area.dispatchUsedToday.includes(key); + if (spent) { + return { + what: `${base} SPENT for today — it comes back at the start of the next Day.`, + spent: true, + }; + } + // Available, but only to whoever is dispatching. Naming the shift length is the difference + // between "not now" and knowing how long "not now" lasts. + if (seatOf(s, s.clock.superintendent) !== seat) { + return { + what: + `${base} Unspent, but IDLE: a device is only used by the district holding the Fedora, ` + + `which moves every ${STAGES_PER_SHIFT} Stages.`, + spent: false, + }; + } + return { what: `${base} Available today, and this district is dispatching.`, spent: false }; +} + export function projectDistrict( s: GameState, seat: SeatIndex, @@ -1287,7 +1342,8 @@ export function projectDistrict( what: cellDescription(card, officeProfile(area.tier).name, row === area.runningRow), links: connectionsFor(card).map(([a, b]) => `${a}${b}`), enhancements: card.enhancements.map(prettyKey), - enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)), + enhancementsWhat: card.enhancements.map((k) => enhancementState(s, area, seat, k).what), + enhancementsSpent: card.enhancements.map((k) => enhancementState(s, area, seat, k).spent), trains: trainsOnCard(s, seat, key), adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null, cars: carsOn(card).map((c) => carLabel(c, seat)), diff --git a/test/display-gaps.test.ts b/test/display-gaps.test.ts index 7cb798a..ce6e2a4 100644 --- a/test/display-gaps.test.ts +++ b/test/display-gaps.test.ts @@ -20,7 +20,7 @@ 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 { crewTrayCount, enhancementText } 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'; @@ -256,3 +256,135 @@ describe('the Campaign Train says whether its speeches are made (#100)', () => { assert.doesNotMatch(trainRules({ trainNumber: 5, trainIsExtra: false }), /SPEECHES/i); }); }); + +/** + * A DISPATCH DEVICE SAYS WHETHER IT IS STILL AVAILABLE TODAY (#101). + * + * Telegraph (+4), Telephone (+8) and Radio (+12) are "once a day, when dispatching facing trains, + * add +N to the other train's number". The device is drawn on its card and its effect text is in the + * tooltip — but `enhancementText(key)` takes only the KEY, so it could not vary with anything, and + * the Radio read "once a day, add +12…" all Day after it had been spent. That is `trainRules` before + * #100, in a different corner of the same view. + * + * THE SECOND HALF IS THE ONE THAT SURPRISES. `spendDispatchBonus` reads + * `areaOf(s, s.clock.superintendent)` — the SUPERINTENDENT's own devices, not the train owner's — + * and the Fedora moves every `STAGES_PER_SHIFT` Stages, four times a Day. So a player's Radio does + * nothing at all for three-quarters of the Day, and is spent automatically, without being asked, + * during the quarter it is theirs to use. The board said neither half. + * + * Shown on EVERY district (Jesse, 2026-09-07), not only the viewer's: it is public, and a rival's + * spent Radio is exactly what you want to know before forcing a meet. `projectDistrict` serves both + * the player's own cells and the common board's `districts`, so one change covers both. + */ +describe('a dispatch device says whether it is still available today (#101)', () => { + /** Puts `key` on the first card of seat 0's district and returns the pieces to assert on. */ + const withDevice = (key: string, opts: { spent?: boolean; fedora?: boolean } = {}) => { + const s = game(); + const area = areaOf(s, 0 as PlayerIndex); + const card = [...area.grid.values()][0]!; + card.enhancements.push(key); + if (opts.spent) area.dispatchUsedToday.push(key); + // The Fedora is a PLAYER; give it to somebody whose seat is not this district's. + s.clock.superintendent = (opts.fedora ? 0 : 1) as PlayerIndex; + const seat = seatOf(s, 0 as PlayerIndex); + const cells = projectDistrict(s, seat as SeatIndex).cells; + const cell = cells.find((c) => c.enhancements.length > 0)!; + const i = cell.enhancementsWhat.findIndex((w) => new RegExp(key, 'i').test(w)); + return { s, area, cell, what: cell.enhancementsWhat[i] ?? '', spent: cell.enhancementsSpent[i] }; + }; + + it('reads as available before it is used, and says what it is worth', () => { + const { what, spent } = withDevice('radio', { fedora: true }); + assert.equal(spent, false, 'an unused device reported itself spent'); + assert.match(what, /\+12/, 'the device no longer says what it is worth'); + assert.doesNotMatch(what, /spent/i, 'an unused device claimed it had been spent'); + }); + + it('says so once it has been spent, and says when it comes back', () => { + const { what, spent } = withDevice('radio', { spent: true, fedora: true }); + assert.equal(spent, true, 'a spent device still reported itself available'); + assert.match(what, /spent/i, 'a spent device read exactly as it did before it was spent'); + assert.match(what, /next Day|tomorrow/i, 'it does not say the device comes back'); + }); + + it('reads differently spent and unspent — the whole of the bug was that it did not', () => { + assert.notEqual( + withDevice('telegraph', { fedora: true }).what, + withDevice('telegraph', { spent: true, fedora: true }).what, + ); + }); + + it('says a device is idle while somebody else holds the Fedora', () => { + const { what } = withDevice('telephone'); + assert.match(what, /Fedora|Superintendent/i, 'nothing said the device is only used while dispatching'); + }); + + it('does not say that when this district IS the Superintendent', () => { + const { what } = withDevice('telephone', { fedora: true }); + assert.doesNotMatch(what, /while .*holds? the Fedora/i); + }); + + /** + * EXACT equality, not "does not mention the Fedora". The first draft asserted the absence of + * /spent|Fedora/i with the Fedora held, and a mutation that removed the `dispatchBonus` guard + * altogether PASSED it — because the leaked text in that case reads "Available today, and this + * district is dispatching", which contains neither word. A test for a field being left alone has + * to compare it to what it should be. + */ + it('leaves an enhancement that is not a dispatch device exactly as it was', () => { + for (const fedora of [true, false]) { + const { what, spent } = withDevice('interlocking', { fedora }); + assert.equal(spent, false, 'a non-dispatch enhancement was marked spendable'); + assert.equal(what, enhancementText('interlocking'), 'an Interlocking was given a dispatch caveat'); + } + }); + + it('comes back when the Day turns, which is what clears the record', () => { + const { s, area } = withDevice('radio', { spent: true, fedora: true }); + area.dispatchUsedToday = []; + const cell = projectDistrict(s, seatOf(s, 0 as PlayerIndex) as SeatIndex).cells.find( + (c) => c.enhancements.length > 0, + )!; + assert.equal(cell.enhancementsSpent.some((x) => x), false, 'a new Day did not restore the device'); + }); + + /** + * SEAT IS NOT PLAYER INDEX, and this is the one place the two are joined: the Fedora is held by a + * PLAYER and the devices sit in an Office Area keyed by SEAT. `advance.ts` carried a comment + * warning that indexing one with the other was safe only while seating was the identity map — it + * read as a live bug and was not one, because `areaOf` resolves through `seatOf`. The comment is + * corrected; this is the guard, because the next reader deserves better than a claim. + * + * §4.4's D12 makes seating a real permutation, so a two-player game where player 1 sits in seat 0 + * is ordinary rather than contrived. + */ + it("marks the SUPERINTENDENT's own district as dispatching under non-identity seating", () => { + const s = game(); + s.seating = [1, 0] as PlayerIndex[]; + assert.notEqual(seatOf(s, 0 as PlayerIndex), 0, 'the premise is gone: seating is still identity'); + s.clock.superintendent = 0 as PlayerIndex; + + const mine = areaOf(s, 0 as PlayerIndex); + [...mine.grid.values()][0]!.enhancements.push('radio'); + const other = areaOf(s, 1 as PlayerIndex); + [...other.grid.values()][0]!.enhancements.push('radio'); + + const whatAt = (player: PlayerIndex): string => { + const cells = projectDistrict(s, seatOf(s, player) as SeatIndex).cells; + return cells.find((c) => c.enhancements.length > 0)!.enhancementsWhat.join(' '); + }; + // The Fedora is player 0's, whatever seat that is. + assert.doesNotMatch(whatAt(0 as PlayerIndex), /IDLE/, "the Superintendent's own device read as idle"); + assert.match(whatAt(1 as PlayerIndex), /IDLE/, "somebody else's device read as dispatching"); + }); + + it('is on every district of the common board, not only the viewer own', () => { + const { s } = withDevice('radio', { spent: true, fedora: true }); + const pub = publicSnapshot(s); + const all = pub.districts.flatMap((d) => d.cells); + assert.ok( + all.some((c) => c.enhancementsSpent.some((x) => x)), + "a spectator cannot see which devices are spent in a player's district", + ); + }); +});