v0.7.9.7 — a device that said it was available all day after it was gone
The last item off 0.7.9.6's sweep, parked there as the one genuine maybe. It had a second half worth more than the first. #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. That is `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. 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. Shown on EVERY district, not only the viewer's (Jesse's call): it is public, and a rival's spent Radio is what you want to know before forcing a meet. What counts as a device is `enhancementRule(key)?.dispatchBonus` rather than 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 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 what the bug would. Rewritten, and the claim is now a test — seating set to a real permutation, and the Superintendent's own district rather than the seat with the same index is the one that reads as dispatching. TWO THINGS MUTATION CAUGHT THAT PASSING DID NOT. A test asserted the ABSENCE of /spent|Fedora/ with the Fedora held, and a mutant with the `dispatchBonus` guard deleted PASSED it — 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, so it asserts equality with `enhancementText` now, in both Fedora states. And the replay wire format needed the field: cells pack positionally, so the flag is index 9 and reads `?? []`, the same tolerance `standingWest` uses — older recordings report no device spent, which is what they drew at the time, so every published replay is unchanged. 943 tests pass, up from 934. NOT VERIFIED AT A TABLE, like 0.7.9.6. #39 and #35 still stand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5boPxP6JHRYMm8adXaF5R
This commit is contained in:
co-authored by
Claude Opus 5
parent
7f4e027258
commit
88a42ae9e2
@@ -19,6 +19,69 @@ page as `v0.1.0 · <sha> · <date>`, 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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+10
-2
@@ -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).
|
||||
|
||||
+11
-1
@@ -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] ? `<tspan class="bs-enh-spent">${esc(n)}</tspan>` : esc(n)))
|
||||
.join(' · ');
|
||||
out += `<text class="bs-enh" x="6" y="26" data-tip="${esc(
|
||||
(cell.enhancementsWhat ?? []).join(' · '),
|
||||
)}">${esc(cell.enhancements.join(' · '))}</text>`;
|
||||
)}">${names}</text>`;
|
||||
}
|
||||
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). */
|
||||
|
||||
+6
-2
@@ -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] ?? [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
+58
-2
@@ -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)),
|
||||
|
||||
+133
-1
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user