Compare commits
3
Commits
d5445badcc
...
312e0301e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
312e0301e0 | ||
|
|
88a42ae9e2 | ||
|
|
7f4e027258 |
+245
@@ -19,6 +19,251 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.7.9.8 — 2026-09-07
|
||||
|
||||
Housekeeping before v0.8.0 — the answer to "anything else that should be looked at first", which
|
||||
turned up one real hole and one stale document.
|
||||
|
||||
### `npm test` did not typecheck, and passed green on a type error (#102)
|
||||
|
||||
`pretest` ran `scripts/build-web.ts`, which invokes `tsc --ignoreConfig` against three web entry
|
||||
points. So it saw only what those three transitively import, under a **weaker** configuration than
|
||||
`tsconfig.json` — no `noUncheckedIndexedAccess`, no `exactOptionalPropertyTypes`, `--types ''` — and
|
||||
never saw `src/server/` or a single file under `test/`.
|
||||
|
||||
Demonstrated rather than argued. Planting `const DELIBERATE_TYPE_ERROR: number = 'not a number';` in
|
||||
`src/server/session.ts`:
|
||||
|
||||
```
|
||||
npm run typecheck → src/server/session.ts(441,7): error TS2322
|
||||
npm test → # fail 0
|
||||
```
|
||||
|
||||
`pretest` is `tsc --noEmit && node scripts/build-web.ts` now, and the same planted error exits 1 with
|
||||
the tests never running.
|
||||
|
||||
**Why this week rather than generally.** v0.8.0 is steps 2-7 of the common board — a display stream,
|
||||
credentials, persistence, a Chromium supervisor — which is almost entirely `src/server/`, exactly the
|
||||
half the test command could not see.
|
||||
|
||||
### The common-board plan had drifted from the code it is the source for (#103)
|
||||
|
||||
`docs/plans/jitsi-common-board.md` was written on 2026-08-27, still said "No implementation has been
|
||||
performed", and is what steps 2-7 will be built from. Step 1 has since shipped across four releases,
|
||||
so every "current code finding" under it described a fault that is now fixed. A document that reads
|
||||
as present tense and is nine days stale sends the next reader to fix things twice.
|
||||
|
||||
Measured: the plan's `PublicFrame` sketch lists four properties never built (`protocolVersion`,
|
||||
`config`, `scoring`, `deckCounts`) and omits **28** that exist. The shape is the real difference —
|
||||
the implementation is flat where the plan grouped things into `clock`, `config`, `scoring` and
|
||||
`deckCounts` objects, so a renderer written from the sketch would not compile against the projection.
|
||||
|
||||
The plan now says all of this at the top and at step 1, names `src/sim/view.ts` and
|
||||
`test/redaction.test.ts`'s allow-list as the authority, keeps the original sketch for its reasoning,
|
||||
and lists what has been gained since. `protocolVersion` is called out as unbuilt rather than quietly
|
||||
dropped — step 2 is the reconnecting display stream and is the first thing that would want one.
|
||||
|
||||
**And one step-1 item is struck off rather than built.** The plan asks for the Red Flag holder on the
|
||||
public player projection, "public game state but currently absent from `Frame`". The premise does not
|
||||
hold here: `decks.redFlags` is written once, in `setup.ts`, from `optionalRules.emergencyToolbox`,
|
||||
and never again — `redFlag.play` emits `phaseEnded` and does not spend it — so every player holds one
|
||||
or none does, decided before the deal. A per-player `redFlagHeld` would be one already-public option
|
||||
copied N times, while telling every reader of the common board that it varies by player and might
|
||||
change mid-game. That is worse than the absence. The invariant is pinned by test so the item is not
|
||||
re-raised from the plan text.
|
||||
|
||||
### Four dead imports, and a measurement for #46
|
||||
|
||||
Removed: `HAND_LIMIT`, left unused in `apply.ts`, `view.ts` and `web/game.ts` when 0.7.9.6
|
||||
consolidated the three copies of the §6.2 test into one, and `actingPlayer` in `web/game.ts`, dead
|
||||
since 0.7.9.5 made `currentActor` delegate. All four were mine.
|
||||
|
||||
Finding them re-measured #46: **`tsc --noUnusedLocals` now finds 40, up from 29 on 2026-08-30.** That
|
||||
entry's prediction — "without the flag this list simply regrows — it is regrowing now" — is a
|
||||
measurement rather than a forecast. The remaining 36 and the flag itself are still open; ten of them
|
||||
still wait on #48 settling what `sim/replay.ts` is for.
|
||||
|
||||
### Proof
|
||||
|
||||
946 tests pass, up from 943. Nothing in this release changes behaviour: the three new tests pin an
|
||||
invariant and the rest is the build command, dead imports and a document.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
@@ -130,7 +130,13 @@ specific paths below have not been exercised at a table. **More testing is plann
|
||||
|
||||
**This is v0.8.0.** One shared, seatless display of the public game, usable on a TV or in OBS on its
|
||||
own and publishable into the table's Jitsi meeting. The plan is `docs/plans/jitsi-common-board.md`,
|
||||
seven steps, of which 1-4 are the useful release and 5-7 are the Jitsi publisher. Items that look
|
||||
seven steps, of which 1-4 are the useful release and 5-7 are the Jitsi publisher.
|
||||
|
||||
**Step 1 is BUILT** — v0.7.9.2 through v0.7.9.5 (#91, #92, #95, #97), with one item struck off
|
||||
rather than implemented (#103). **The plan was reconciled against the code in v0.7.9.8** and now says
|
||||
which of its "current code findings" are history: it had drifted badly enough to send the next reader
|
||||
fixing things twice. Steps 2-7 were never implemented and their findings have NOT been re-verified —
|
||||
check each before building on it. See **Reference · #103**. Items that look
|
||||
like screen polish live here because they need step 4's ordered presentation mechanism and nothing
|
||||
cheaper.
|
||||
|
||||
@@ -363,11 +369,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**.
|
||||
|
||||
@@ -1526,6 +1527,14 @@ discarded wrongly.
|
||||
run it.** Swept 2026-08-30 and deliberately NOT fixed in the same pass, at Jesse's call — it is
|
||||
a wide, mechanical change and v0.7.9 had enough in it.
|
||||
|
||||
**RE-MEASURED 2026-09-07: 40, up from 29 in eight days.** The prediction below — "without the
|
||||
flag this list simply regrows" — is now a measurement rather than a forecast. **Four of the new
|
||||
ones were mine and are removed in v0.7.9.8**: `HAND_LIMIT` left unused in `apply.ts`, `view.ts`
|
||||
and `web/game.ts` when the three copies of the §6.2 test were consolidated into one (#45), and
|
||||
`actingPlayer` in `web/game.ts`, dead since `currentActor` began delegating (#96). Removing my
|
||||
own leavings is not doing this item — the remaining 36 and the FLAG are still open, and the
|
||||
`sim/replay.ts` ten still wait on #48.
|
||||
|
||||
**The recurrence is the point, not the 29.** `SIDE_GAP` and `CHIP_W` in `board-svg.ts` are both
|
||||
Gitea#18 leftovers — constants for a wrapped layout that no longer exists. `SIDE_GAP` was
|
||||
removed by hand on 2026-08-30 only because someone happened to read the file while closing the
|
||||
@@ -1584,11 +1593,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 +1861,175 @@ 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` 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
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
102. ~~**`npm test` did not typecheck, and passed green on a type error in the server.**~~ — done
|
||||
2026-09-07 in v0.7.9.8. `pretest` ran `scripts/build-web.ts`, which invokes
|
||||
`tsc --ignoreConfig` against three web entry points — so it saw only what those three
|
||||
transitively import, under a WEAKER configuration than `tsconfig.json` (no
|
||||
`noUncheckedIndexedAccess`, no `exactOptionalPropertyTypes`, `--types ''`). `src/server/` and
|
||||
every file under `test/` were never checked by the test command at all.
|
||||
|
||||
Demonstrated rather than argued: `const DELIBERATE_TYPE_ERROR: number = 'not a number';` in
|
||||
`src/server/session.ts` gives `npm run typecheck` a TS2322 and `npm test` a clean
|
||||
`# fail 0`. `pretest` is `tsc --noEmit && node scripts/build-web.ts` now, and the same planted
|
||||
error exits 1 with the tests never running.
|
||||
|
||||
**Why it mattered THIS week rather than generally.** v0.8.0 is steps 2-7 of the common board —
|
||||
a display stream, credentials, persistence, and a Chromium supervisor — which is almost
|
||||
entirely `src/server/` and is exactly the half the test command could not see. Found while
|
||||
answering "anything else before 0.8.0", which is the only reason it was found at all: nothing
|
||||
about a green suite would ever have said so.
|
||||
|
||||
103. ~~**The common-board plan had drifted from the code it is the source for.**~~ — done 2026-09-07
|
||||
in v0.7.9.8. `docs/plans/jitsi-common-board.md` was written 2026-08-27, still said "No
|
||||
implementation has been performed", and is what steps 2-7 will be built from. Step 1 has since
|
||||
shipped across four releases, so every "current code finding" under it described a fault that
|
||||
is now fixed — a document that reads as present tense and is nine days stale sends the next
|
||||
reader to fix things twice.
|
||||
|
||||
Measured: the plan's `PublicFrame` sketch lists four properties that were never built
|
||||
(`protocolVersion`, `config`, `scoring`, `deckCounts`) and omits **28** that exist. The shape
|
||||
is the real difference — the implementation is FLAT where the plan grouped things into `clock`,
|
||||
`config`, `scoring` and `deckCounts` objects. A renderer written from the sketch would not
|
||||
compile against the projection.
|
||||
|
||||
The plan now says so at the top and at step 1, names `src/sim/view.ts` and
|
||||
`test/redaction.test.ts`'s allow-list as the authority, keeps the original sketch for its
|
||||
reasoning, and lists what has been gained since (`crewTrays`, `queued`, `heldAtLimits`,
|
||||
`enhancementsSpent`). **`protocolVersion` is called out as unbuilt** rather than quietly
|
||||
dropped: step 2 is the reconnecting display stream and is the first thing that would want one.
|
||||
|
||||
**One step-1 item is STRUCK OFF rather than deferred:** "add the Red Flag holder to the public
|
||||
player projection." The premise does not hold here. `decks.redFlags` is written once, in
|
||||
`setup.ts`, from `config.optionalRules.emergencyToolbox`, and never again — `redFlag.play`
|
||||
emits `phaseEnded` and does not spend it — so every player holds one or none does, decided
|
||||
before the deal. A per-player `redFlagHeld` would be `optionalRules.emergencyToolbox` copied N
|
||||
times, already public, while telling every reader of the common board that it varies by player
|
||||
and might change mid-game. That is worse than the absence. Pinned by test so it is not
|
||||
re-raised from the plan text: if the rule ever becomes per-player, the test fails.
|
||||
|
||||
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,6 +1,17 @@
|
||||
# Station Master Jitsi Common Board Implementation Plan
|
||||
|
||||
**Status:** Final planning document. No implementation has been performed.
|
||||
**Status (2026-09-07):** **STEP 1 IS BUILT AND SHIPPED. Steps 2-7 are unimplemented.**
|
||||
|
||||
Step 1 landed across four releases rather than one — v0.7.9.2 (the two narration leaks), v0.7.9.4
|
||||
(the projection helpers and the redaction net), v0.7.9.5 (the narration path), and v0.7.9.8 (this
|
||||
reconciliation). One of its items is struck off rather than built; see § Public game projection.
|
||||
|
||||
**This document has drifted from the code and is no longer the authority on what exists.** It was
|
||||
written on 2026-08-27 against the code of that date, and the "Current code findings" under each step
|
||||
describe faults that were then real — several are now fixed, and reading them as present tense will
|
||||
send you to fix things twice. Where a step is marked built, `src/sim/view.ts`, `src/server/`, and the
|
||||
tests named in TODO.md are the authority. Steps 2-7 were never implemented and their findings have
|
||||
NOT been re-verified against the current code; check each before building on it.
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -44,6 +55,38 @@ The sibling repository had unrelated local modifications and untracked files. Th
|
||||
|
||||
Introduce dedicated allow-listed types. Do not derive them with `Omit<Frame, ...>` because new private `Frame` fields could then leak automatically.
|
||||
|
||||
> **RECONCILED 2026-09-07 (v0.7.9.8).** Step 1 is BUILT, and what shipped is not shaped like the
|
||||
> sketch below. This block was the design; `PublicFrame` in `src/sim/view.ts` is now the authority,
|
||||
> and `test/redaction.test.ts`'s allow-list is the enumeration of it that fails when it changes.
|
||||
> **Read those two, not this**, when building steps 2-7. The differences that matter:
|
||||
>
|
||||
> - **The shape is FLAT, not grouped.** There is no `clock`, `config`, `scoring` or `deckCounts`
|
||||
> object. Their contents sit at the top level — `day`, `stage`, `clock` (a time string), `phase`,
|
||||
> `phaseKey`, `actor`, `superintendent`, `deck`, `departments`, `departmentDepth`, `salvage`,
|
||||
> `yards`, `mode`, `days`, `optionalRules`, `houseRules`, `minCombinedRevenue`,
|
||||
> `maxCollisionsPerDay`, `maxCollisionsTotal`, `collisionsToday`, `collisionsTotal`, `status`,
|
||||
> `outcome`, `extraDays`, `extensionVotes`, `official`, `tally`, `openingRolls`, `timetable`,
|
||||
> `timetableWhat`, `trains`, `players`, `division`, `districts`.
|
||||
> - **`protocolVersion` was NOT built** and exists nowhere in the repo. Step 2 is the reconnecting
|
||||
> display stream, which is the first thing that would want one — decide there whether to add it,
|
||||
> rather than assuming it is already on the wire.
|
||||
> - **`redFlagHeld` is STRUCK OFF**, not deferred. See below.
|
||||
> - **Fields gained since this was written** that the renderer should know about: `crewTrays` and
|
||||
> `queued` (#98, the Crew Tray pool and the trains waiting for one), and on each district's cells
|
||||
> `heldAtLimits` (#99, a train stopped on the Limit Track) and `enhancementsSpent` (#101, a
|
||||
> dispatch device spent for the Day).
|
||||
>
|
||||
> **Why `redFlagHeld` is struck off rather than built.** The premise does not hold in this codebase.
|
||||
> `decks.redFlags` is written in exactly one place — `setup.ts`, from
|
||||
> `config.optionalRules.emergencyToolbox` — and never again; `redFlag.play` emits `phaseEnded` and
|
||||
> does not spend it. So every player holds one or none does, decided before the deal. A per-player
|
||||
> `redFlagHeld` would be `optionalRules.emergencyToolbox` copied N times, already public, while
|
||||
> telling every reader of the common board that it varies by player and may change mid-game. The
|
||||
> invariant is pinned by test (`test/display-gaps.test.ts`) so this does not get re-raised from the
|
||||
> plan text: if the rule ever becomes per-player, that test fails.
|
||||
|
||||
The design as originally written, kept for the reasoning:
|
||||
|
||||
```ts
|
||||
type PublicPlayerView = {
|
||||
index: PlayerIndex;
|
||||
@@ -145,6 +188,24 @@ Expose sanitized state, last transition time, and a safe error summary through t
|
||||
|
||||
## Step 1 — Secure public-state projection
|
||||
|
||||
> **BUILT — v0.7.9.2 through v0.7.9.5.** Everything in "Required changes" below shipped except the
|
||||
> Red Flag holder, which is struck off (§ Public game projection). Mapping to the work items in
|
||||
> TODO.md: the projection helpers and `currentActorOfState` are **#95**, the systematic redaction
|
||||
> net and its allow-list are **#91**, the seed and blind-draw narration leaks are **#92**, and
|
||||
> "stop passing the full game log into `frameFor()`" is **#97** — which also fixed a reconnect bug
|
||||
> the duplicate had been masking, so read that entry before touching narration in step 2.
|
||||
>
|
||||
> **The findings below are as of 2026-08-27 and are now HISTORY, not a task list.** Two are worth
|
||||
> carrying forward anyway: districts must be keyed by SEAT with ownership resolved through
|
||||
> `playerAtSeat` (Employee Rotation), and the public view must be composed UPWARD from shared
|
||||
> helpers, never by calling the player `snapshot()` once per seat. Both are load-bearing for step 3.
|
||||
>
|
||||
> One finding was struck off on measurement rather than fixed: it warns that a display reading
|
||||
> `clock.currentActor` could highlight the wrong district during a decision. Across six seeds and
|
||||
> 3,600 decision points that field and `actingPlayer` never disagreed. `currentActorOfState` exists
|
||||
> anyway as the one place to ask — and **#96** later found a real instance of the same class in a
|
||||
> state nobody had checked, the §3.3 vote, where the game is not `active` at all.
|
||||
|
||||
### Current code findings
|
||||
|
||||
`src/sim/view.ts` currently builds `Frame` for one real viewer and defaults that viewer to player zero. It combines shared table state, one district, and viewer-private fields. Calling it for a spectator would silently expose player zero’s district and private information.
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.7.9.5",
|
||||
"version": "0.7.9.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"pretest": "node scripts/build-web.ts",
|
||||
"pretest": "tsc --noEmit && node scripts/build-web.ts",
|
||||
"test": "node --test test/*.test.ts test/**/*.test.ts",
|
||||
"build:web": "node scripts/build-web.ts",
|
||||
"serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1",
|
||||
|
||||
+15
-3
@@ -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).
|
||||
@@ -727,7 +735,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;
|
||||
|
||||
+4
-5
@@ -16,7 +16,6 @@
|
||||
|
||||
import {
|
||||
FREIGHT_PROFILES,
|
||||
HAND_LIMIT,
|
||||
LABORER_ACTIONS_PER_LOAD,
|
||||
MAX_CONSIST,
|
||||
REALIGNMENTS,
|
||||
@@ -61,6 +60,7 @@ import {
|
||||
decisionActor,
|
||||
officeNodeFor,
|
||||
isOperationalRail,
|
||||
overHandLimit,
|
||||
playerAtSeat,
|
||||
pooled,
|
||||
railFacingOf,
|
||||
@@ -1099,10 +1099,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 --------------------------------------------------------
|
||||
|
||||
+14
-1
@@ -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,
|
||||
|
||||
+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). */
|
||||
|
||||
+63
-7
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+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] ?? [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
+158
-10
@@ -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,
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
import {
|
||||
ACTION_CARDS,
|
||||
ENHANCEMENT_CARDS,
|
||||
HAND_LIMIT,
|
||||
MAINLINE_MODIFIER_CARDS,
|
||||
MAINLINE_PROFILES,
|
||||
MANEUVER_CARDS,
|
||||
@@ -35,6 +34,8 @@ import {
|
||||
REALIGNMENTS,
|
||||
OFFICE_ORDER,
|
||||
SPACE_USE_CARDS,
|
||||
STAGES_PER_SHIFT,
|
||||
crewTrayCount,
|
||||
enhancementRule,
|
||||
enhancementText,
|
||||
industryProfile,
|
||||
@@ -46,8 +47,8 @@ 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 { actingPlayer, carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } 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';
|
||||
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
|
||||
@@ -73,6 +74,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.
|
||||
*
|
||||
@@ -104,6 +113,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 +475,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 +778,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;
|
||||
@@ -1200,6 +1261,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,
|
||||
@@ -1234,7 +1341,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)),
|
||||
@@ -1515,6 +1623,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 +1776,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 +1953,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 +2003,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(
|
||||
|
||||
+4
-9
@@ -30,7 +30,7 @@ import type { Intent } from '../engine/intents.ts';
|
||||
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,
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
DEFAULT_HOUSE_RULES,
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
HAND_LIMIT,
|
||||
LEGACY_HOUSE_RULES,
|
||||
collectiveRevenueFloor,
|
||||
houseRules,
|
||||
@@ -246,7 +245,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 +311,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 +330,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 +1058,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 +1106,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;
|
||||
}
|
||||
|
||||
+17
-6
@@ -1957,12 +1957,23 @@ function renderActions(
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -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<boolean> {
|
||||
const seq = nextSeq++;
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* 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, 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';
|
||||
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<string, unknown>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* THE RED FLAG HOLDER IS NOT A PER-PLAYER FACT, so it does not belong on the public player view.
|
||||
*
|
||||
* `docs/plans/jitsi-common-board.md` step 1 asks for one: "Add the Red Flag holder to the public
|
||||
* player projection. It is public game state but is currently absent from `Frame`", and its
|
||||
* `PublicPlayerView` carries `redFlagHeld: boolean`. Every other step-1 item shipped across v0.7.9.2
|
||||
* to v0.7.9.5; this one is STRUCK OFF instead, because the premise does not hold in this codebase.
|
||||
*
|
||||
* `decks.redFlags` is written in exactly one place — `setup.ts`, from
|
||||
* `config.optionalRules.emergencyToolbox` — and never again. There is no `.set` anywhere else, and
|
||||
* `redFlag.play` (the intent gated on holding one) emits a `phaseEnded` event and does not spend it.
|
||||
* So every player holds one or none of them does, decided before the first card is dealt.
|
||||
*
|
||||
* A `redFlagHeld` on each player would therefore be `optionalRules.emergencyToolbox` copied N times
|
||||
* — already on the public projection — while implying to every reader of the common board that it
|
||||
* varies by player and might change during a game. That is worse than the absence.
|
||||
*
|
||||
* This test exists so the plan item is not re-raised from the plan text: if the rule ever DOES
|
||||
* become per-player, this fails and the projection is the right place to look.
|
||||
*/
|
||||
describe('the Red Flag holding is the Emergency Toolbox option, not a per-player fact', () => {
|
||||
const dealt = (emergencyToolbox: boolean): GameState =>
|
||||
createGame({
|
||||
id: 'g',
|
||||
seed: 99,
|
||||
config: { ...config, optionalRules: { ...config.optionalRules, emergencyToolbox } },
|
||||
playerNames: ['Ann', 'Bob', 'Cy'],
|
||||
});
|
||||
|
||||
for (const toolbox of [true, false]) {
|
||||
it(`gives every player the same answer with the toolbox ${toolbox ? 'on' : 'off'}`, () => {
|
||||
const s = dealt(toolbox);
|
||||
const held = s.players.map((p) => s.decks.redFlags.get(p.index) === true);
|
||||
assert.deepEqual(held, [toolbox, toolbox, toolbox], 'the Red Flag has become a per-player fact');
|
||||
});
|
||||
}
|
||||
|
||||
it('is already public, through the option it comes from', () => {
|
||||
const pub = publicSnapshot(dealt(true));
|
||||
assert.equal(
|
||||
pub.optionalRules.emergencyToolbox,
|
||||
true,
|
||||
'a spectator cannot tell whether the hand limit is three or four',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user