Files
Jesse 9f3b92d08e v0.4.7 — the switching game: track order, the cut on your own card, and four rules
Eight play reports and one design that had been written up and not built. The
through-line is switching: what a card can hold, which end of a train a cut comes
off, which way a train meets cars standing on the line, and what the board and the
log say about all of it.

TRACK ORDER FOR STANDING CARS, AND THE CUT ON YOUR OWN CARD

Two reports turned out to be one root cause. `TrackCard.standing` claimed "in track
order (§A.3)" and had no defined orientation at all, while `CrewTray.consist` does
(nose first, relative to facing) — so every transfer between them was a conversion
nothing performed. §A.3 says what it should be: cars occupy the track "in the same
order they originally held, left-to-right". Left-to-right is west-to-east, and that
is now the defined orientation of `standing` and of an industry track through
`carsOn`. It is the board's orientation, not the train's, so it does not change when
a different train touches the card.

  - Setting out is batch-invariant. Four cars at once, four singles and two pairs
    parked three different orders, one of them physically impossible. Successive
    cuts off the same end stack up towards the engine, so the insertion point is the
    train's own place in the row.
  - Approaching a cut from either end now mirrors. `couples` is built nearest-first
    along the direction of travel and reverses onto the nose, so the farthest car met
    ends up nose-most — which is what makes a run-around worth its Move.
  - A train no longer drives through its own cut. The walk began at the neighbour of
    the start square and never read the start card, so a crew could set cars out and
    pull straight away from them. Coupling is mandatory (§A.4) and your own square is
    no exception; the cut counts against the four-car limit. Setting out off the end
    you are not leaving by still works.

`CrewTray.standingWest` records where a train stands among the cars on its card — a
train may set out off both ends on one square, so which side a cut is on is not
recoverable from the array alone.

On the board, the cut is drawn split at the train — west cars left, east cars right,
engine in the gap — and each car's tooltip says whether it stands ahead of or behind
the engine. The history says which end a cut came off, and a move's button separates
"takes your own boxcar back off this card" from cars found standing on the line.

Decided: taking your own cut back on the square you are standing on is UNDOING the
drop. It is exempt from trains 3/4's per-location freight budget, X13's "drop but not
pick up" and X22's "empties only", and it refunds the budget the drop spent.
Otherwise a legal-looking drop becomes silently one-way.

Measured, 200 paired seeds, developer bot: -0.55 revenue (t = -3.63), freight revenue
1.11 -> 0.56. That cost is the bot's, not the rule's — its trains run engine-first,
so at a stub industry it sets a car out between itself and the only way out, and the
correct play is §A.5's facing-point move, which is the cross-turn planning TODO.md
already records as out of reach of any bot. Filtering self-recoupling moves out of its
options took recoupling from 625 of 1,029 set-outs to 101 of 677, and all 101 that
remain are that case. Read the number as a bot measurement, not a balance one.

THE SUPERINTENDENT'S RULING NAMES THE TRAINS IT IS ABOUT

Reported: the Superintendent could not tell which train he was clearing. The heading
asks the question now — "may Train 6 follow Train 4 onto the same Mainline card?" —
and the trains moved to the FRONT of each button, because the button splits its label
at the first em-dash and showed only the head.

AN INDUSTRY TRACK HOLDS FOUR CARS, LIKE EVERY OTHER CARD

Reported at undo 188: "we wanted to drop two cars, but were only allowed to drop one."
An industry track was built as long as its box count, so a one-box industry had room
for one car. Box count is how much WORK an industry can hold, not how much RAIL it
has. Ordinary track was the other exception, unbounded; both are gone and every card
holds four.

THE FREIGHT AGENT MAY STAGE A LOAD BEFORE THE CAR IS THERE

§6.3 asks nothing of the industry track — the empty car belongs to §9.3's Load the
car, which is the Laborer's action. The gate now lives only there, so cargo can wait
on the dock while the car to ship it in is still being switched in. Nothing can jam:
a load in a green box is waiting, not stuck.

THE TRUCK DOCK UNLOADS, AND BRINGS NOBODY

+1 inbound, no Laborer. It printed +1 outbound and +1 Laborer, which made it a
longer-host-list copy of Forklifts. Beside Packing Sheds it now does nothing at all,
and the hand tooltip says so before it is played.

Also in this release, from the days before: Mainline card tooltips computed from the
crossing rule; an Extra starts from the Division Point its number sends it to; a
modifier's suppressed grant comes back when a Whistle Post is upgraded; the Oil
Refinery and the Grocer's Warehouse ship as well as receive, per the card reference;
and the dormant defences name the attack they answer. `.claude/` is now gitignored —
it holds Claude Code's worktrees, i.e. a second checkout of this repository.

570 tests, typecheck clean. The three published replays were re-recorded twice —
legality changed, so bot play changed. Full detail in CHANGELOG.md.
2026-08-19 12:12:30 -04:00

234 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Track order for standing cars, and the cut you left on your own card
> **BUILT IN v0.4.7.** Kept as the record of why the model is what it is — the reproductions below
> are the evidence for west-to-east, and none of them is re-derivable from the code that fixed them.
>
> Two departures from the plan as written, both recorded here so the document does not read as a
> description of the code:
>
> - **Step 3 does not flip the card's cut.** The plan asked for `board-svg` to apply the tray strip's
> `facing === 'e' ? reverse : as-is` to the standing cars too. Once `standing` is defined west to
> east that flip would be wrong — the board is a map and draws west on the left, so the array is
> already in drawing order. What the renderer got instead is the half that actually answers the
> report: the row is split at the train, west cars left and east cars right, with the engine in the
> gap, and each car's tooltip says whether it is ahead of or behind the engine.
> - **One new field after all.** The plan argued a cut's end is *derivable* from position in the
> array. It is not, in the one case that matters: a train may set out off both ends on the same
> square, and nothing in the array records where the train itself stands among them. That is
> `CrewTray.standingWest`, kept on the tray rather than the card because it describes a
> relationship — with no train there, a cut has no near or far side.
>
> **Step 5 was decided (i)**: taking your own cut back on the square you are standing on is undoing
> the drop, exempt from the printed pick-up restrictions, and it refunds the freight budget.
Plan for the next session. Two reported problems that turned out to be one problem.
---
## The reports
1. **Drops.** "If I put cars off the nose on a given track, and my next move is go forward, I need to
couple those cars right back on. If I drop cars off the back and I move back, then I will
automatically recouple the cars onto the back of my train. Right after dropping my cars I need to
be able to see if those cars are ahead or behind the train."
2. **Ordering.** "When dropping all 4 cars, order was reversed. It worked properly if we dropped cars
individually. Also, when adding four cars, again the order was reversed."
---
## The single root cause
`TrackCard.standing` is a bare `RollingStock[]` whose doc comment claims "in track order (§A.3)" and
which in fact has **no defined orientation at all**. `CrewTray.consist` *is* oriented — nose first,
relative to `facing` — so every transfer between the two is an orientation change that nothing
performs.
§A.3 states the requirement plainly:
> It is critical that cars are loaded into and unloaded from the Crew Tray, and occupy the track, in
> the same order they originally held, **left-to-right**.
Left-to-right is west-to-east. That is the orientation `standing` is missing.
### Reproduced, against the working tree
Scratch scripts only; reproduce with a tray on a plain straight, consist `[boxcar hopper reefer
tank]`, `engineAt = 0`.
**(a) Batch size changes the parked order.** Tail drops:
| drops | `card.standing` afterwards |
| --- | --- |
| `count: 4` | `[boxcar hopper reefer tank]` |
| `count: 1` ×4 | `[tank reefer hopper boxcar]` |
| `count: 2` ×2 | `[reefer tank boxcar hopper]` — physically impossible |
`carsDropped` always does `carsOn(card).push(...stock)` (`apply.ts:1657`). Each successive tail cut is
left *outboard* of the previous one on the track, so it belongs at the other end of the array. Nose
drops are batch-invariant by luck, so only tail drops show it.
**(b) Coupling is orientation-blind.** A train running **east** onto a parked cut and a train running
**west** onto the same cut yield the **identical** consist. They must mirror. `couples` is
accumulated in path order in `exploreMoves` (`track.ts:448`) and `unshift`ed wholesale in
`carsCoupled` (`apply.ts:1618`), with no reference to which way the engine met the cars.
**(c) The display mismatch — what was actually seen on screen.** `board-svg.ts:736` draws the tray
strip as `t.facing === 'e' ? [...items].reverse() : items`, so the nose lands on the right for an
east-facing train (a deliberate fix, documented in the comment above it). The card's standing cars at
`board-svg.ts:~633` are drawn in raw array order with no such flip. The same physical cut therefore
reads one way in the tray and the other way on the card — and which one looks "reversed" flips with
batch size *and* with train facing.
The round trip (drop, run away, back up, collect) is internally self-consistent today, which is why
this survived: whatever order got parked comes back in that order. Only the picture disagreed.
### Why this settles the earlier design question
The previous session offered two models for recording which end a cut sits at:
- **Option A** — one cut per card plus a `standingEnd?: Port` marker. Cheap.
- **Option B** — give `standing` a real orientation.
Option A leaves (a), (b) and (c) in place. **Do Option B.** With orientation defined, "which end is
the cut at" is *derivable* from position in the array and needs no new field — so B is also the
smaller change once the ordering bugs are counted as in scope, which they now are.
---
## The model
**`TrackCard.standing` (and `Facility.industryTrack.cars`, via `carsOn`) is ordered WEST to EAST.**
That is the printed rule, it is the convention the board already draws in, and it is the only
orientation that is a property of the board rather than of whichever train last touched the card.
Two derived quantities, both cheap:
- **A cut's end.** With a train on the card, cars before the train in track order are at its west
end, cars after it are at its east end. Combined with `railFacing`, that is "ahead" or "behind".
- **Approach order.** A train travelling east meets a card's cars in array order; travelling west, in
reverse array order.
The nose/tail blocking rule then reads: **a move that leaves the card through the end the cut sits at
must couple that cut first** — onto the nose when running forward, onto the tail when reversing,
which is what the existing `toNose: !i.reverse` already gives.
### The two conversions, stated once
- **Consist → track.** `consist` is nose-first; nose is `facing`. So an east-facing train's consist
reads east-to-west and must be **reversed** on the way onto the card; a west-facing train's reads
west-to-east and goes on **as-is**.
- **Track → consist.** Build `couples` **near-to-far along the direction of travel** (reverse each
card's array when travelling west, and visit cards in path order). Then a nose coupling is
`unshift(...[...couples].reverse())` and a tail coupling is `push(...couples)` unchanged.
Check the tail case by hand: backing east into a cut, the nearest car couples closest to the existing
tail, and `consist` runs nose-to-tail, so near-before-far pushed in order is right. Check the nose
case: running east into a cut, the *farthest* car ends up nose-most, so the reverse is right.
---
## Work, in order
Each step should land green on its own.
### 1. Define the orientation and fix the drop
- `state.ts` — rewrite the `standing` doc comment to state west-to-east outright, and say `carsOn`
inherits it. That comment is currently the thing that is wrong.
- `apply.ts` `carsDropped` reducer (~1649) — insert at the correct end for the dropping train's
`facing`, reversing the cut where the conversion above says to. A tail cut from an east-facing
train goes at the **west** end of whatever is already on the card; a nose cut from the same train
goes at the **east** end.
- Tests: batch-invariance. `count: 4`, `1×4` and `2×2` must all produce the same `standing`, from
both ends, at both facings. Direct regression for report (2).
### 2. Fix the coupling
- `track.ts` `exploreMoves` (~448) — build `couples` near-to-far along travel, per the conversion
above. Needs the exit port at each hop, which the walk already has in `MoveStep`.
- `apply.ts` `carsCoupled` (~1612) — reverse on `toNose`.
- Tests: the mirror property. Approaching the same parked cut eastbound and westbound must give
mirrored consists, never identical ones.
- Tests: round trip at both facings and all batch sizes — drop, run clear, back up, collect; the
consist must come back identical.
### 3. Fix the picture
- `view.ts` `CellView` — `cars: string[]` is flat and carries no relation to `train`. It needs enough
for the renderer to place the cut relative to the engine. Preferred: keep `cars` in track order
(west-to-east, matching state) and let the renderer flip, exactly as it already does for the tray
strip.
- `board-svg.ts` (~633) — apply the same `facing === 'e' ? reverse : as-is` flip to the cut that line
736 applies to the tray, and lay the cut on the correct **side** of the train chip rather than in
the fixed slot row. This is the real UI work and answers "I need to see if those cars are ahead or
behind".
- Tooltips: "standing here" becomes "standing ahead of the engine" / "behind the engine" when a train
shares the card.
### 4. Layer on the blocking rule
- `track.ts` `exploreMoves` — seed the walk's `couples` with the start card's cut when the cut sits at
the exit end. Today the walk begins at `neighbour(start, initialExit)` and never reads
`carsOn(startCard)` at all, so a train drives through its own cut in both directions. Regularise
the loop-back case at the same time: `couples` already picks up the start card if the walk circles
back to it, because only `results.push`/`block` are guarded by `!sameCoord(node.coord, start)`.
- `apply.ts` `switch.move` build (~1230) — `lifted` is `[...dest.path.map(s => s.coord), i.to]` and can
never contain `start`. It must, or the recoupled cut is not cleared off the card.
- `ctx.consistSize` — the start-card cut must count toward `MAX_CONSIST`, so `tooManyCars` bites at
the start square. Its message should name the cut on your own card or it will read as nonsense.
- Tests: encode §A.5's two worked examples move-for-move. Both must remain playable; the trailing
example's step 1 ("drop the back two on Card F, move train to Card B") is the specific case that
must *not* recouple, and it is the strongest single check that the rule is right.
### 5. Decide the printed-rule interaction — Jesse's call
Dropping a freight car spends the square's budget for trains 3/4 (`oneFreightPerLocation`). Recoupling
it on the way out would then be refused with `FREIGHT_WORKED_HERE`, so the train could only ever back
away from its own cut. Same shape for X13 `dropOnly` and X22 `pickUpEmptiesOnly`.
Two defensible answers:
- **(i)** Recoupling your own cut on the square you are standing on is *undoing the drop* — exempt
from those restrictions, and it refunds the freight budget.
- **(ii)** It is a pickup like any other; a train that sets out off its nose is committed to
reversing. Harsher, but never strands a train permanently, since backing up stays legal.
Leaning (i): (ii) makes a legal-looking drop silently one-way, which is exactly the kind of trap the
"why can't I move" panel exists to prevent.
### 6. Bot and fixtures
- `bot.ts` — the "drop the nose cut to un-bury the engine" heuristic (~1268) and "spot the back car at
this facility" (~1352) now have direction consequences. The bot cannot produce an illegal move
(legality is enforced upstream) but it will silently get worse until its scoring knows about the
cut.
- `public/replays/seed-*.json` will diverge — legality changes, so bot play changes. Regenerate, as
was done last release.
- **`docs/station-master-seed58228926-day6.json` no longer replays at all.** It stops at intent #2,
`mainline.modify c108` → `NO_SUCH_CARD`, because uncommitted deck changes shifted card ids. It
cannot serve as the regression case for these reports. Capture a fresh save against current code
reproducing the same switching sequence, and keep it.
---
## Files expected to change
| File | Why |
| --- | --- |
| `src/engine/state.ts` | `standing` orientation, doc comment |
| `src/engine/track.ts` | direction-aware `couples`; start-card cut; consist limit |
| `src/engine/apply.ts` | `carsDropped`, `carsCoupled`, `switch.move` `lifted`, budget decision |
| `src/sim/view.ts` | `CellView.cars` orientation and its relation to `train` |
| `src/sim/board-svg.ts` | flip the cut, place it beside the chip, tooltips |
| `src/sim/narrate.ts` | say which end was set out; distinguish recoupling own cut (~138) |
| `src/sim/bot.ts` | direction-aware set-out heuristics |
| `test/apply.test.ts`, `test/train-rules.test.ts`, `test/web.test.ts`, `test/replay.test.ts` | drop/couple batteries, §A.5 examples |
| `public/replays/seed-*.json` | regenerate |
## Not in scope
- Leaving a coach at the Office (§A.4 / trains 7/8) — separate open item in `TODO.md`.
- Any change to `maneuver.flyingSwitch`'s target rules. It drops onto a card with no train on it, so
the cut has no "end" to sit at; it just needs to land in west-to-east order like any other cut.