Compare commits
3
Commits
| 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
|
## 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
|
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
|
**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`,
|
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
|
like screen polish live here because they need step 4's ordered presentation mechanism and nothing
|
||||||
cheaper.
|
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
|
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**.
|
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
|
- [ ] **#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**.
|
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
|
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.
|
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
|
**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
|
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
|
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
|
screen, which is how the first attempt at pinning this failed for a reason unrelated to the
|
||||||
fix.
|
fix.
|
||||||
|
|
||||||
**The two plumbing ones — `viewerSeat` and `overHandLimit` — are DEFERRED (Jesse, 2026-08-30):
|
**The two plumbing ones are DONE — 2026-09-07 in v0.7.9.6, and the answer was different for
|
||||||
"leave it for now."** They cost nothing today and v0.7.9 had enough in it. The decision when it
|
each.** Deferred by Jesse 2026-08-30 ("leave it for now") on the understanding that the decision
|
||||||
comes is delete-or-document, not a patch: either strip the field and the `Session`-interface
|
would be delete-or-document rather than a patch.
|
||||||
method, or write down what each is being carried ahead of a consumer FOR, so the next audit
|
|
||||||
does not re-flag them.
|
- **`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".
|
#### #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()`
|
`Frame.lines` is the solitaire and replay path, which builds its Frames through `snapshot()`
|
||||||
directly and is untouched.
|
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
|
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:
|
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
|
"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
|
# 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
|
## 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.
|
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
|
```ts
|
||||||
type PublicPlayerView = {
|
type PublicPlayerView = {
|
||||||
index: PlayerIndex;
|
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
|
## 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
|
### 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.
|
`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",
|
"name": "station-master",
|
||||||
"version": "0.7.9.5",
|
"version": "0.7.9.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"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",
|
"test": "node --test test/*.test.ts test/**/*.test.ts",
|
||||||
"build:web": "node scripts/build-web.ts",
|
"build:web": "node scripts/build-web.ts",
|
||||||
"serve:web": "node scripts/build-web.ts && npx --yes http-server dist -p 8080 -c-1",
|
"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 mine = tray.trainNumber ?? 99;
|
||||||
const theirs = other.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);
|
const area = areaOf(s, s.clock.superintendent);
|
||||||
|
|
||||||
// Best device first — Radio (+12) beats Telephone (+8) beats Telegraph (+4).
|
// 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
|
* 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.
|
* 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;
|
const rules = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules;
|
||||||
if (!rules) return false;
|
if (!rules) return false;
|
||||||
if (rules.expedite) return true;
|
if (rules.expedite) return true;
|
||||||
|
|||||||
+4
-5
@@ -16,7 +16,6 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
FREIGHT_PROFILES,
|
FREIGHT_PROFILES,
|
||||||
HAND_LIMIT,
|
|
||||||
LABORER_ACTIONS_PER_LOAD,
|
LABORER_ACTIONS_PER_LOAD,
|
||||||
MAX_CONSIST,
|
MAX_CONSIST,
|
||||||
REALIGNMENTS,
|
REALIGNMENTS,
|
||||||
@@ -61,6 +60,7 @@ import {
|
|||||||
decisionActor,
|
decisionActor,
|
||||||
officeNodeFor,
|
officeNodeFor,
|
||||||
isOperationalRail,
|
isOperationalRail,
|
||||||
|
overHandLimit,
|
||||||
playerAtSeat,
|
playerAtSeat,
|
||||||
pooled,
|
pooled,
|
||||||
railFacingOf,
|
railFacingOf,
|
||||||
@@ -1099,10 +1099,9 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
case 'draw.end': {
|
case 'draw.end': {
|
||||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||||
if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
||||||
// §6.2 — "the player must reduce his hand to no more than three cards".
|
// §6.2 — "the player must reduce his hand to no more than three cards". `overHandLimit`
|
||||||
const hand = s.decks.hands.get(player) ?? [];
|
// (state.ts) is the one copy of that test; the Frame and the page ask the same function.
|
||||||
const limit = s.decks.redFlags.get(player) ? HAND_LIMIT + 1 : HAND_LIMIT;
|
return overHandLimit(s, player) ? 'HAND_LIMIT' : null;
|
||||||
return hand.length > limit ? 'HAND_LIMIT' : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Freight Agent --------------------------------------------------------
|
// -- Freight Agent --------------------------------------------------------
|
||||||
|
|||||||
+14
-1
@@ -17,7 +17,7 @@ import type {
|
|||||||
OfficeTier,
|
OfficeTier,
|
||||||
TrackGeometry,
|
TrackGeometry,
|
||||||
} from './content.ts';
|
} from './content.ts';
|
||||||
import { MAX_CONSIST, officeProfile } from './content.ts';
|
import { HAND_LIMIT, MAX_CONSIST, officeProfile } from './content.ts';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Identifiers
|
// Identifiers
|
||||||
@@ -1040,6 +1040,19 @@ export function turnOf(s: GameState, player: PlayerIndex): TurnState {
|
|||||||
return t;
|
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 {
|
export function freshTurn(moves: number): TurnState {
|
||||||
return {
|
return {
|
||||||
option: null,
|
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.
|
// 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
|
// 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.
|
// 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(
|
out += `<text class="bs-enh" x="6" y="26" data-tip="${esc(
|
||||||
(cell.enhancementsWhat ?? []).join(' · '),
|
(cell.enhancementsWhat ?? []).join(' · '),
|
||||||
)}">${esc(cell.enhancements.join(' · '))}</text>`;
|
)}">${names}</text>`;
|
||||||
}
|
}
|
||||||
if (selectedTrain) {
|
if (selectedTrain) {
|
||||||
/**
|
/**
|
||||||
@@ -1336,6 +1345,7 @@ export const BOARD_CSS = `
|
|||||||
.bs-mod{font:10px ui-monospace,monospace}
|
.bs-mod{font:10px ui-monospace,monospace}
|
||||||
text.bs-mod{fill:#c8a04a}
|
text.bs-mod{fill:#c8a04a}
|
||||||
.bs-enh{fill:#7fb0e6;font:9px ui-monospace,monospace}
|
.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}
|
.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
|
/* 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). */
|
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.
|
* 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 { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
||||||
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } 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';
|
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];
|
* TRAINS HELD FOR WANT OF A CREW TRAY (§7) — the scarcity mechanic, made visible (#98).
|
||||||
if (due !== null && due !== undefined && s.freeTrays.length === 0) {
|
*
|
||||||
|
* 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({
|
out.push({
|
||||||
where: `Train ${due}`,
|
where: `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber ?? '—'}`,
|
||||||
why: 'due to depart but HELD — no free Crew Tray',
|
why:
|
||||||
severity: 'stuck',
|
'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;
|
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
|
// `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.
|
// 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;
|
return { ...f, cells } as unknown as Frame;
|
||||||
});
|
});
|
||||||
@@ -264,7 +264,7 @@ export function rehydrateCells(
|
|||||||
facs: unknown[],
|
facs: unknown[],
|
||||||
): unknown[] {
|
): unknown[] {
|
||||||
return packed.map((row) => {
|
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[]];
|
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
|
// 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
|
// (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,
|
// 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.
|
// which read as 0 — the whole cut east of the engine, which is what they used to draw anyway.
|
||||||
standingWest: p[8] ?? 0,
|
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.
|
* drift into two different pictures of the same board.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { regionOfTransit } from '../engine/advance.ts';
|
import { isExpedited, regionOfTransit } from '../engine/advance.ts';
|
||||||
import {
|
import {
|
||||||
areaAtSeat,
|
areaAtSeat,
|
||||||
areaOf,
|
areaOf,
|
||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
ACTION_CARDS,
|
ACTION_CARDS,
|
||||||
ENHANCEMENT_CARDS,
|
ENHANCEMENT_CARDS,
|
||||||
HAND_LIMIT,
|
|
||||||
MAINLINE_MODIFIER_CARDS,
|
MAINLINE_MODIFIER_CARDS,
|
||||||
MAINLINE_PROFILES,
|
MAINLINE_PROFILES,
|
||||||
MANEUVER_CARDS,
|
MANEUVER_CARDS,
|
||||||
@@ -35,6 +34,8 @@ import {
|
|||||||
REALIGNMENTS,
|
REALIGNMENTS,
|
||||||
OFFICE_ORDER,
|
OFFICE_ORDER,
|
||||||
SPACE_USE_CARDS,
|
SPACE_USE_CARDS,
|
||||||
|
STAGES_PER_SHIFT,
|
||||||
|
crewTrayCount,
|
||||||
enhancementRule,
|
enhancementRule,
|
||||||
enhancementText,
|
enhancementText,
|
||||||
industryProfile,
|
industryProfile,
|
||||||
@@ -46,8 +47,8 @@ import {
|
|||||||
mainlineDescription,
|
mainlineDescription,
|
||||||
} from '../engine/content.ts';
|
} from '../engine/content.ts';
|
||||||
import type { Intent } from '../engine/intents.ts';
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import type { Facility, GameConfig, GameState, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
import type { Facility, GameConfig, GameState, OfficeArea, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
||||||
import { actingPlayer, carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
|
import { actingPlayer, carsOn, overHandLimit, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
|
||||||
import type { Direction, Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
|
import type { Direction, Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
|
||||||
import type { Port } from '../engine/track.ts';
|
import type { Port } from '../engine/track.ts';
|
||||||
import { connectionsFor, slopeOfPair, variantsFor } 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.
|
* `toString()`), so it cannot reach the card catalogue itself.
|
||||||
*/
|
*/
|
||||||
enhancementsWhat: string[];
|
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.
|
* 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.
|
* standing in front of them — the card is face down in a box somewhere by then.
|
||||||
*/
|
*/
|
||||||
what: string;
|
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.
|
* 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.
|
* there is only one; the first thing you want to know at a four-player table.
|
||||||
*/
|
*/
|
||||||
viewer: number;
|
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;
|
viewerSeat: number;
|
||||||
/**
|
/**
|
||||||
* §4.4's opening D12 per player, and the roll that chose the Superintendent — kept so a client
|
* §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'] {
|
function trainsOnCard(s: GameState, viewerSeat: SeatIndex, key: string): CellView['trains'] {
|
||||||
const out: 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) {
|
for (const [id, t] of s.trays) {
|
||||||
if (t.position.at !== 'grid' || t.position.seat !== viewerSeat) continue;
|
if (t.position.at !== 'grid' || t.position.seat !== viewerSeat) continue;
|
||||||
if (`${t.position.coord.row},${t.position.coord.col}` !== key) 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
|
* 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.
|
* 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(
|
export function projectDistrict(
|
||||||
s: GameState,
|
s: GameState,
|
||||||
seat: SeatIndex,
|
seat: SeatIndex,
|
||||||
@@ -1234,7 +1341,8 @@ export function projectDistrict(
|
|||||||
what: cellDescription(card, officeProfile(area.tier).name, row === area.runningRow),
|
what: cellDescription(card, officeProfile(area.tier).name, row === area.runningRow),
|
||||||
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
|
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
|
||||||
enhancements: card.enhancements.map(prettyKey),
|
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),
|
trains: trainsOnCard(s, seat, key),
|
||||||
adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null,
|
adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null,
|
||||||
cars: carsOn(card).map((c) => carLabel(c, seat)),
|
cars: carsOn(card).map((c) => carLabel(c, seat)),
|
||||||
@@ -1515,6 +1623,27 @@ export function projectSharedTable(s: GameState) {
|
|||||||
? `Mainline card ${t.position.index}`
|
? `Mainline card ${t.position.index}`
|
||||||
: `Office Area (${t.position.coord.col},${t.position.coord.row})`,
|
: `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,
|
viewer,
|
||||||
viewerSeat,
|
viewerSeat,
|
||||||
handCount: (s.decks.hands.get(viewer) ?? []).length,
|
handCount: (s.decks.hands.get(viewer) ?? []).length,
|
||||||
overHandLimit:
|
overHandLimit: overHandLimit(s, viewer),
|
||||||
(s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT),
|
|
||||||
objective: objectiveOf(s, viewer),
|
objective: objectiveOf(s, viewer),
|
||||||
runningRow,
|
runningRow,
|
||||||
limits,
|
limits,
|
||||||
@@ -1825,6 +1953,12 @@ export function cardDescription(s: GameState, id: string): string {
|
|||||||
export function trainRules(t: {
|
export function trainRules(t: {
|
||||||
trainNumber: number | null;
|
trainNumber: number | null;
|
||||||
trainIsExtra: boolean;
|
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 {
|
}): string {
|
||||||
const p = trainProfile(t.trainNumber ?? 0, t.trainIsExtra);
|
const p = trainProfile(t.trainNumber ?? 0, t.trainIsExtra);
|
||||||
if (!p) return '';
|
if (!p) return '';
|
||||||
@@ -1869,11 +2003,25 @@ export function trainRules(t: {
|
|||||||
if (p.rules.pickUpEmptiesOnly) {
|
if (p.rules.pickUpEmptiesOnly) {
|
||||||
parts.push('EMPTIES ONLY — it may not couple a loaded car. A caboose is not a load.');
|
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) {
|
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
|
// It is released and switched exactly like any other train — the restriction is on where it may
|
||||||
// be LEFT, not on when it leaves.
|
// be LEFT, not on when it leaves.
|
||||||
parts.push(
|
parts.push(
|
||||||
|
|||||||
+4
-9
@@ -30,7 +30,7 @@ import type { Intent } from '../engine/intents.ts';
|
|||||||
import { legalActions } from '../engine/legal.ts';
|
import { legalActions } from '../engine/legal.ts';
|
||||||
import { createGame } from '../engine/setup.ts';
|
import { createGame } from '../engine/setup.ts';
|
||||||
import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.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 { playerAtSeat } from '../engine/state.ts';
|
||||||
import { cuesFor, narrate } from '../sim/narrate.ts';
|
import { cuesFor, narrate } from '../sim/narrate.ts';
|
||||||
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
// 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_HOUSE_RULES,
|
||||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||||
HAND_LIMIT,
|
|
||||||
LEGACY_HOUSE_RULES,
|
LEGACY_HOUSE_RULES,
|
||||||
collectiveRevenueFloor,
|
collectiveRevenueFloor,
|
||||||
houseRules,
|
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
|
* 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.
|
* 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.
|
* 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 {
|
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||||
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
|
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
|
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
|
||||||
// first, then let the clock take over.
|
// first, then let the clock take over.
|
||||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
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 {
|
export function newMultiplayerGame(seed: number, config: GameConfig, playerNames: string[]): Game {
|
||||||
const state = createGame({ id: `mp-${seed}`, seed, config, playerNames });
|
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' });
|
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||||
/**
|
/**
|
||||||
* NO SEED AT A TABLE WITH MORE THAN ONE SEAT (Gitea#20 step 1).
|
* 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.
|
* button with a reason instead of hiding a move that has simply become illegal.
|
||||||
*/
|
*/
|
||||||
export function overHandLimit(game: Game, seat: PlayerIndex = 0): boolean {
|
export function overHandLimit(game: Game, seat: PlayerIndex = 0): boolean {
|
||||||
const hand = game.state.decks.hands.get(seat) ?? [];
|
return overHandLimitOf(game.state, seat);
|
||||||
const limit = game.state.decks.redFlags.get(seat) ? HAND_LIMIT + 1 : HAND_LIMIT;
|
|
||||||
return hand.length > limit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1110,7 +1106,6 @@ export function submit(game: Game, intent: Intent, as: PlayerIndex | null = null
|
|||||||
}
|
}
|
||||||
game.history.push(intent);
|
game.history.push(intent);
|
||||||
record(game, result.events, actor);
|
record(game, result.events, actor);
|
||||||
game.mustPlayCard = overHandLimit(game);
|
|
||||||
drain(game);
|
drain(game);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-6
@@ -1957,12 +1957,23 @@ function renderActions(
|
|||||||
}
|
}
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
}
|
}
|
||||||
if (
|
/**
|
||||||
f.phaseKey === 'localOps' &&
|
* WHY "END LOCAL OPERATIONS" IS NOT THERE (#45).
|
||||||
f.option === 'draw' &&
|
*
|
||||||
!menu.options.some((i) => i.type === 'draw.end')
|
* 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
|
||||||
// The hand being counted is the ACTOR's — they are the one who cannot end the turn.
|
* 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;
|
const hand = f.handCount;
|
||||||
/**
|
/**
|
||||||
* WHEN NOTHING IN HAND MAY BE DISCARDED, SAY SO AND SAY WHAT TO DO INSTEAD.
|
* WHEN NOTHING IN HAND MAY BE DISCARDED, SAY SO AND SAY WHAT TO DO INSTEAD.
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
handPlayable,
|
handPlayable,
|
||||||
isOutOfTurn,
|
isOutOfTurn,
|
||||||
newGame,
|
newGame,
|
||||||
overHandLimit,
|
|
||||||
submit,
|
submit,
|
||||||
toSave,
|
toSave,
|
||||||
undo,
|
undo,
|
||||||
@@ -58,8 +57,6 @@ export type Session = {
|
|||||||
seat(): PlayerIndex;
|
seat(): PlayerIndex;
|
||||||
/** Whose turn it is, or null when the game is over or waiting on nothing. */
|
/** Whose turn it is, or null when the game is over or waiting on nothing. */
|
||||||
actor(): PlayerIndex | null;
|
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. */
|
/** Which cards in hand are playable right now, in hand order. */
|
||||||
handPlayable(): boolean[];
|
handPlayable(): boolean[];
|
||||||
/**
|
/**
|
||||||
@@ -161,7 +158,6 @@ export function createLocalSession(seed: number, options?: NewGameOptions): Loca
|
|||||||
menu: () => actionMenu(game),
|
menu: () => actionMenu(game),
|
||||||
seat: () => 0,
|
seat: () => 0,
|
||||||
actor: () => currentActor(game),
|
actor: () => currentActor(game),
|
||||||
overHandLimit: () => overHandLimit(game),
|
|
||||||
handPlayable: () => handPlayable(game),
|
handPlayable: () => handPlayable(game),
|
||||||
submit: async (intent: Intent) => {
|
submit: async (intent: Intent) => {
|
||||||
// Seat 0 is the solitaire player, and the extension vote (Gitea#11) is the one intent that
|
// 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 },
|
menu: () => menu ?? { options: [], direct: [], placeable: [], hand: [], makeUp: null },
|
||||||
seat: () => seat,
|
seat: () => seat,
|
||||||
actor: () => need().actor,
|
actor: () => need().actor,
|
||||||
overHandLimit: () => need().overHandLimit,
|
|
||||||
handPlayable: () => (menu?.hand ?? []).map((h) => h.playNow !== null),
|
handPlayable: () => (menu?.hand ?? []).map((h) => h.playNow !== null),
|
||||||
async submit(intent: Intent): Promise<boolean> {
|
async submit(intent: Intent): Promise<boolean> {
|
||||||
const seq = nextSeq++;
|
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
|
/** 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. */
|
* — `newGame` (game.ts) hardcodes one player, so it cannot construct this for a multi-seat game. */
|
||||||
const wrap = (s: GameState): 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 = {
|
const competitive: GameConfig = {
|
||||||
mode: 'competitive',
|
mode: 'competitive',
|
||||||
|
|||||||
@@ -443,6 +443,10 @@ describe('#91 — nothing private survives serialisation, in any state', () => {
|
|||||||
'openingRolls',
|
'openingRolls',
|
||||||
// Where every train is standing.
|
// Where every train is standing.
|
||||||
'trains',
|
'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.
|
// The board itself.
|
||||||
'division', 'districts',
|
'division', 'districts',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import assert from 'node:assert/strict';
|
|||||||
import { readFileSync, readdirSync } from 'node:fs';
|
import { readFileSync, readdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
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 { createLocalSession } from '../src/web/session.ts';
|
||||||
import { seatLabel } from '../src/sim/view.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.seat(), 0);
|
||||||
assert.equal(session.actor(), currentActor(session.game));
|
assert.equal(session.actor(), currentActor(session.game));
|
||||||
assert.deepEqual(session.handPlayable(), handPlayable(session.game));
|
assert.deepEqual(session.handPlayable(), handPlayable(session.game));
|
||||||
assert.equal(session.overHandLimit(), overHandLimit(session.game));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user