Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6657241de |
+160
@@ -19,6 +19,166 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.8.0.14 — 2026-09-17
|
||||
|
||||
Six reports from the Day 2-3 playtest of v0.8.0.13. Two were the same shortage seen from opposite
|
||||
ends, one was a rule working as designed that Jesse has now reversed, and three were the screen
|
||||
saying too little or too much.
|
||||
|
||||
**THIS RELEASE STRANDS GAMES IN PROGRESS, INCLUDING THE ONE ON THE BOX.** Modifiers are now bounded
|
||||
by the Limits, which makes a once-legal move illegal — so a save holding one is refused at that move.
|
||||
Measured on the playtest save: `whistle-6945.day3.stage10` stops at intent 528 of 539, eleven moves
|
||||
from the end, on `card.play c55 → (-2,4)`. The file is untouched and v0.8.0.13 still finishes it.
|
||||
|
||||
### Every coach in the game was stuck in the Classification Yard
|
||||
|
||||
Two reports, one cause. The Sparrow (trains 5/6) calls for three coaches and nothing else, and ran
|
||||
the length of the Division empty; Tom had a loaded coach standing at his Depot and could find no way
|
||||
to unload the passengers. Both are §2.2 and §9.2 acting exactly as printed, and the combination is a
|
||||
one-way ratchet: boarding discards the emptied coach into the **Classification** Yard, detraining
|
||||
draws a fresh empty **out of the Division Yard**, and Classification comes back only when the
|
||||
Division Yard runs bare.
|
||||
|
||||
Measured across the save, because the shape of it is the point: sixteen coaches in the Division Yard
|
||||
at setup, **zero from Day 2 Stage 8 onward**, fifteen piled up in Classification — while the Division
|
||||
Yard sat at 46-47 freight cars and stopped draining, so the refill was never going to fire. Every
|
||||
passenger operation in the game was over, and four of the twelve timetabled trains carry nothing but
|
||||
coaches.
|
||||
|
||||
**Jesse's ruling: the rule stands, the game says so loudly.** The same ruling Gitea#2 got, and the
|
||||
same one 0.8.0.13 gave the blocked Depot panel. Three places say it now:
|
||||
|
||||
- **A train made up short reports it** (`makeUpShort`). `trainNeedingCars` returns null both when a
|
||||
consist is full and when the yard holds nothing it will take — the same answer for "done" and
|
||||
"cannot be done" — so the phase moved on in silence, and the only trace was a MADE UP line
|
||||
promising "now taking cars" with nothing after it. It now names what the card wanted, how many are
|
||||
waiting in Classification, and how far the Division Yard is from bare.
|
||||
- **The yard panel warns while the condition lasts**, rather than only at the moment a train goes
|
||||
short.
|
||||
- **The Depot's blocked panel already said it**, and still does — it was correct all along, off to
|
||||
the side, and read by nobody who had gone looking in the wrong place.
|
||||
|
||||
### A modifier may no longer stand outside the Limits
|
||||
|
||||
Reported as a bug and it was working as designed — the design being a call of Jesse's, recorded in
|
||||
`apply.ts`: a Modifier is not track, so a Facility standing at the limit kept all nine of its spots,
|
||||
because bounding the card looked like it would make it unplayable exactly where a district ends.
|
||||
**Reversed.** What decided it is what the board shows — Transmission Lines at (-2,4) with the sign at
|
||||
column 3 reads as building outside your own territory, and §8.1 and §10 both reason about what lies
|
||||
inside a player's Limits.
|
||||
|
||||
The feared case was checked on the move that prompted the change rather than argued away: the Power
|
||||
Plant was at (-1,3) against a sign at column 3, and (-2,2) and (-2,3) were both free, legal and
|
||||
inside. Six of the nine spots survive a Facility at the limit, and the sign moves outward as the
|
||||
Running Track grows (§2.1, Gap 4a).
|
||||
|
||||
### The history panel was full of coordinates
|
||||
|
||||
"Does switching show up in history at all?" — it all did. A six-Move turn wrote a line per move,
|
||||
every one of them a pair of grid coordinates, plus one per mandatory coupling. **Switching is still
|
||||
logged in full**; what changed is what the panel DRAWS: the line saying somebody switched, the first
|
||||
move of the turn, work at an **industry**, the Small Yard sort, and a closing summary.
|
||||
|
||||
**The suppressed lines are still written, marked `trace`, and that is not a detail.** Dropping these
|
||||
events was the first attempt and the step-queue suite caught it: `dwellForStep` gives a step no dwell
|
||||
when it produced no narration, so a switching move with no line became a silent step and the board
|
||||
stopped replaying switching altogether — the exact thing v0.8.0 was built to let a table watch. The
|
||||
tone is the seam. The caption keeps its text, the panel filters the tone.
|
||||
|
||||
**The last move rides in the closing line rather than being kept in place**, and it has to: nothing
|
||||
knows a move was the last until the turn is over, and by then the line has been written and streamed
|
||||
to every client (`server/session.ts` § `linesSince`), so it cannot be revised. `switchingEnded` says
|
||||
what the turn cost and where the crew was left — "finished switching — 3 of 6 Moves used, leaving
|
||||
Train 10 at the Small Yard".
|
||||
|
||||
Places are named rather than pointed at: `at the Freight House` and `the Small Yard` instead of
|
||||
`(1,1)`. Two smaller things fell out of reading these lines properly. Every move line ended with
|
||||
"The crew chip on the grid carries the whole train with it" — six times a turn, when the opener
|
||||
already says it once, and it pushed the useful half of the line out of the caption row. And the move
|
||||
count said "of 6" with the 6 hardcoded, which is simply wrong on a Reduced-Visibility night Stage
|
||||
where a turn gets five; the turn now carries its own allowance.
|
||||
|
||||
### Making up a train says which train
|
||||
|
||||
"I did not see anything in the history about making up train 10." It was all there — a MADE UP line
|
||||
and one line per car — and every one of those lines read "the train being made up", so a player
|
||||
looking back for train 10 found nothing under that name. `carPlacedOnTrain` and `carPassed` now carry
|
||||
the number, the way `trainArrived` carries its owner. Also "a empty tank" is now "an empty tank".
|
||||
|
||||
### The Small Yard offered permutations, not trains
|
||||
|
||||
The menu read `re-order consist [1,2,3,0]` — the engine's own array indices offered to a person. The
|
||||
option Jesse wanted was the **first of the five** and he could not tell which one it was. Each option
|
||||
now reads as the train it would build: `re-order to engine · loaded hopper · empty tank · caboose ·
|
||||
loaded boxcar (front to back)`.
|
||||
|
||||
One of the five re-ordered nothing at all and would still have spent a Move — bringing the LAST car
|
||||
to the end is the identity — and a two-car train's "full reversal" duplicated its only real option.
|
||||
Both are filtered by the resulting ORDER rather than by the case that produced them, so a new
|
||||
generator cannot bring either back. No typed-order box: `check` already accepts any permutation, so
|
||||
one is buildable, and Jesse declined it as more to go wrong for a labelling problem.
|
||||
|
||||
### A Small Yard may now put cars ahead of the engine
|
||||
|
||||
Jesse's own open question, discussed and then built. **Two sources disagreed and the design notes
|
||||
won.** The v0.4.5 card text says a train there may "reorder its entire consist **and put the engine
|
||||
at the nose**", which is what `consistSorted` did unconditionally. `implications.md`, drawn from the
|
||||
design source, says a train "may sort itself into any order, **including cars ahead of the engine**"
|
||||
— and records that the Small Yard is the answer the switching puzzle was designed to have.
|
||||
|
||||
`switch.sortConsist` carries an optional `engineAt`, absent meaning the nose, so every save written
|
||||
before this replays to exactly the train it built. **The menu did not multiply.** Offering every car
|
||||
order at every engine position takes a four-car consist from four options to twenty, which is the
|
||||
unreadability that started this; instead the engine is a separate short list offered against the
|
||||
consist as it stands — four re-orders and four engine positions, eight readable options. A player
|
||||
who wants both spends two Moves, the same price the yard charges for any second sort.
|
||||
|
||||
**§8.2 needed no new code, and the reason is worth stating because the obvious guess is wrong.**
|
||||
`badlyMadeUp` is deliberately direction-free: a train with its whole consist ahead of the engine is a
|
||||
PUSHING train and fit to run. What it refuses is a broken-backed train — the engine buried among its
|
||||
own cars — and a caboose anywhere but the end away from the engine. So the hold Jesse asked for was
|
||||
already there, and the button now warns before the Move is spent by asking that same predicate rather
|
||||
than keeping a copy of it.
|
||||
|
||||
That warning immediately earned itself: **every one of the eight options on train 10 is refused by
|
||||
§8.2**, including the arrangement asked for at the table, because that train carries a caboose and
|
||||
each sort moves it off the rear. The menu says so now instead of spending a Move to find out. It is
|
||||
also the correct answer rather than a gap — that train is ALREADY made up, so every sort on offer
|
||||
would break it — and the labels distinguish the two cases outright: `MADE UP, ready to leave` or
|
||||
`HELD at the Office: <why>`.
|
||||
|
||||
**A made-up order is always on the menu for a train that needs one**, which is worth stating because
|
||||
it looks as though it might not be. A yard sort serves two errands — pulling one car out to an end to
|
||||
be spotted, and putting the train back together to leave — and the curated orders are written for the
|
||||
first. They cover the second as a by-product: "bring car k to the tail" is enumerated for EVERY car,
|
||||
so bringing the CABOOSE to the tail is always among them. An explicit "make it up to leave" option
|
||||
was written and then deleted, because it produced exactly that order and was dropped by the dedupe
|
||||
every time.
|
||||
|
||||
### West to east, the way the board draws it
|
||||
|
||||
"Front to back" is not a direction a table can read: which end is the front depends on which way the
|
||||
train is pointed, and the board has drawn the crew strip **west on the left** since v0.8.0 —
|
||||
reversing the consist for an east-facing train so its nose lands at the east end, with the engine as
|
||||
a ◀ or ▶ arrow. The sort labels now read the same way, so the button and the picture describe the
|
||||
same train and "ahead of" and "behind" the engine are read off the strip rather than asserted:
|
||||
|
||||
```
|
||||
re-order — west to east: loaded boxcar · caboose · empty tank · loaded hopper · ENGINE ▶ · HELD at the Office: the caboose must be at the rear of the train
|
||||
put the whole consist ahead of the engine — west to east: ENGINE ▶ · caboose · empty tank · loaded hopper · loaded boxcar
|
||||
```
|
||||
|
||||
`badlyMadeUp`'s "1 car(s) ahead of it" is a real plural now; it reaches a player through the held
|
||||
train's reason as well as through these labels.
|
||||
|
||||
### A role says what it is for
|
||||
|
||||
Tom reached for the Freight Agent to unload passengers, which it has never done — detraining is a
|
||||
**Porter's** action in the Cargo phase. Both halves were working and neither was visible. The
|
||||
Freight Agent, Porter and Laborer groups now carry a sentence saying what the role does, where the
|
||||
role is chosen, rather than leaving a player who picked the wrong one to discover it by finding
|
||||
nothing there.
|
||||
|
||||
## 0.8.0.13 — 2026-09-16
|
||||
|
||||
Nine reports from the Day 1–2 playtest of v0.8.0.12. One was a real bug that cost a car, one was a
|
||||
|
||||
@@ -46,6 +46,15 @@ deliberately no longer names one: it went stale for six releases.
|
||||
from the game. Dwell is assigned **by kind** — a switching move holds the screen, turn bookkeeping
|
||||
costs nothing — and is tunable per viewer without a rebuild. Solitaire runs the same path, which is
|
||||
where its automatic phases finally get a visible beat.
|
||||
|
||||
**The caption and the history panel are not the same list**, since 2026-09-17. Switching is logged
|
||||
in full; a move from the middle of a turn writes its line as tone `trace`, so the step still
|
||||
carries it — the board captions the move and earns its dwell, and `dwellForStep` pays nothing for a
|
||||
step that said nothing — while the history panel filters the tone out. What the panel draws is the
|
||||
line saying somebody switched, the FIRST move, work at an **industry**, the Small Yard sort, and a
|
||||
closing summary. The last move rides in that summary rather than being kept in place: nothing knows
|
||||
a move was the last until the turn is over, by which time the line has been written and streamed to
|
||||
every client, so it cannot be revised.
|
||||
**Not yet checked in a browser:** the mechanism is proven server-side against a live SSE stream and
|
||||
the page is proven not to throw, but nobody has watched a bot switch on screen.
|
||||
- **Not built** — the opponent-directed cards (the Action and Space-use categories, held out of every
|
||||
|
||||
@@ -79,7 +79,7 @@ Not items. Things that are true of every change, and that have gone wrong when s
|
||||
3. **Multiplayer, sessions and operations** — #8 #7 #76 #77 #79
|
||||
4. **The screen** — #44 #81 #33 #36
|
||||
5. **Replays and saved games** — #14 #47 #48 #49 #50 #51 #52
|
||||
6. **Rules** — #12 #80 #82 #83 #85
|
||||
6. **Rules** — #12 #80 #82 #83 #85 #108
|
||||
7. **Play balance** — #61 #62 #63 #64 #67 #68 #69 #70 #71 #72 #73 #66 #65 #74
|
||||
8. **The bot** — #104 #105 #106 #41 #57 #59 #54 #58 #55 #56 #60
|
||||
9. **Code health and housekeeping** — #46 #45 #84 #87
|
||||
@@ -407,6 +407,42 @@ need RAR or Jesse rather than code.**
|
||||
- [ ] **#85** — The 0.4.9 playtest line is behind on a rules ruling, and that was checked rather than
|
||||
assumed. See **Reference · #85**.
|
||||
|
||||
- [x] **#107** — **May a Small Yard put cars on the NOSE of the engine? YES** — raised by Jesse
|
||||
2026-09-17, discussed the same day and built. Two sources disagreed: the v0.4.5 card text says
|
||||
a train there reorders "and puts the engine at the nose", `implications.md` says "any order,
|
||||
INCLUDING cars ahead of the engine". The design notes won.
|
||||
|
||||
`switch.sortConsist` gained an optional `engineAt` (absent = the nose, so older saves replay
|
||||
unchanged). The menu did NOT multiply: the engine is a separate short list offered against the
|
||||
consist as it stands, so a four-car train has eight options rather than twenty, and a player
|
||||
wanting both a re-order and an engine move spends two Moves. §8.2 needed no new code —
|
||||
`badlyMadeUp` already holds a broken-backed train, and is deliberately direction-free, so a
|
||||
PUSHING train (whole consist ahead of the engine) is fit to run. The button warns first, by
|
||||
asking that predicate rather than copying it.
|
||||
|
||||
Labels read WEST TO EAST with the engine drawn as the board's own ◀ / ▶ arrow, because "front
|
||||
to back" depends on which way the train points and the board has reversed east-facing consists
|
||||
since v0.8.0. Each says `MADE UP, ready to leave` or `HELD at the Office: <why>`.
|
||||
|
||||
- [ ] **#108** — **The coach ratchet: every coach ends up in the Classification Yard and never comes
|
||||
back.** RULED 2026-09-17 — *the rule stands, the game says so loudly* — and recorded here
|
||||
because the ruling was made on one game's evidence and the balance question behind it is open.
|
||||
|
||||
§9.2 boarding discards the emptied coach into **Classification**; detraining draws a fresh
|
||||
empty **out of the Division Yard**; §2.2 returns Classification only when the Division Yard runs
|
||||
bare. Coaches therefore move one way only. **Measured over `whistle-6945` (3 Days, 539
|
||||
intents):** 16 coaches in the Division Yard at setup, **0 from Day 2 Stage 8 to the end**, 15
|
||||
in Classification — while the Division Yard held steady at 46-47 freight cars, so the refill
|
||||
could not fire. From that point no passenger can board or detrain anywhere on the board, and
|
||||
four of the twelve timetabled trains (1/2 Crack Limited, 5/6 Sparrow) carry nothing but
|
||||
coaches.
|
||||
|
||||
The two changes that would break the ratchet were put up and declined for now: sending the
|
||||
emptied coach back to the **Division** Yard instead of Classification (a one-line change to the
|
||||
boarding reducer), or amending §2.2 to refill when the Division Yard holds no car of a NEEDED
|
||||
type rather than only when bare. **Revisit with a second game's data** — one game cannot tell a
|
||||
rule from a seed.
|
||||
|
||||
---
|
||||
|
||||
## Play balance
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.8.0.13",
|
||||
"version": "0.8.0.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
+43
-3
@@ -31,12 +31,13 @@ import {
|
||||
houseRules,
|
||||
officeProfile,
|
||||
mainlineProfile,
|
||||
consistSize,
|
||||
} from './content.ts';
|
||||
import type { Direction, MainlineEntry, MainlineKind } from './content.ts';
|
||||
import type { CarType, Direction, MainlineEntry, MainlineKind } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
|
||||
// the legality test cannot disagree about which train is being assembled.
|
||||
import { areaAtSeat, areaOf, occupancyFor, trainNeedingCars } from './apply.ts';
|
||||
import { acceptsCar, areaAtSeat, areaOf, isBeingMadeUp, occupancyFor, trainNeedingCars } from './apply.ts';
|
||||
import { legalActions } from './legal.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, GridCoord, Outcome, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
||||
import { cloneTally, coordKey, freshTurns, isExtendable, playerAtSeat, playerLeftOf, pooled, railFacingOf, subdivisions, totalRevenue, turnOf } from './state.ts';
|
||||
@@ -393,6 +394,45 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
return { events, needsInput: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* SAY SO WHEN A TRAIN GOT NOTHING, before the round is over and the train runs (playtest,
|
||||
* 2026-09-16: "train 5, the sparrow, has no coaches, which seems strange").
|
||||
*
|
||||
* `trainNeedingCars` returns null both when every consist is full and when the Division Yard holds
|
||||
* nothing a short train will take — the same answer for "done" and for "cannot be done" — so the
|
||||
* phase moved on in silence and the only trace was a MADE UP line promising "now taking cars". The
|
||||
* Sparrow calls for three coaches and left empty twice in one game.
|
||||
*
|
||||
* REPORTED HERE RATHER THAN AT THE MADE-UP MOMENT, because a train made up early in the round can
|
||||
* still be filled by a later placement; only once the round has nothing left to offer is the
|
||||
* shortfall a fact. This is reached exactly once per Stage — the next line enters the Mainline
|
||||
* Phase — so the report cannot repeat.
|
||||
*/
|
||||
for (const tray of s.trays.values()) {
|
||||
if (!isBeingMadeUp(tray) || tray.trainNumber === null) continue;
|
||||
const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
|
||||
if (!profile) continue;
|
||||
const category = (t: CarType): 'freight' | 'coach' | 'caboose' =>
|
||||
t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
|
||||
// What the card still wants: asked of `acceptsCar` per category, so a full category and a
|
||||
// category barred by the card's own rules answer the same way here as they do to a player.
|
||||
const missing = (['freight', 'coach', 'caboose'] as const).filter((cat) => {
|
||||
const sample: CarType = cat === 'coach' ? 'coach' : cat === 'caboose' ? 'caboose' : 'boxcar';
|
||||
return acceptsCar(tray, sample);
|
||||
});
|
||||
if (missing.length === 0) continue;
|
||||
events.push({
|
||||
type: 'makeUpShort',
|
||||
trainNumber: tray.trainNumber,
|
||||
isExtra: tray.trainIsExtra,
|
||||
placed: tray.consist.length,
|
||||
wanted: consistSize(profile.consist),
|
||||
missing: [...missing],
|
||||
waiting: s.yards.classificationYard.filter((c) => missing.includes(category(c.type))).length,
|
||||
divisionYardHolds: s.yards.divisionYard.length,
|
||||
});
|
||||
}
|
||||
|
||||
return { events: [...events, ...enterPhase(s, 'mainline')], needsInput: false };
|
||||
}
|
||||
|
||||
@@ -544,7 +584,7 @@ export function badlyMadeUp(tray: CrewTray): string | null {
|
||||
const pulling = tray.engineAt === 0;
|
||||
const pushing = tray.engineAt === n;
|
||||
if (!pulling && !pushing) {
|
||||
return `not made up — the engine is buried in the train, ${tray.engineAt} car(s) ahead of it`;
|
||||
return `not made up — the engine is buried in the train, ${tray.engineAt} car${tray.engineAt === 1 ? '' : 's'} ahead of it`;
|
||||
}
|
||||
const caboose = tray.consist.findIndex((c) => c.type === 'caboose');
|
||||
if (caboose === -1) return null;
|
||||
|
||||
+95
-16
@@ -974,6 +974,16 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
const seen = new Set(i.order);
|
||||
if (seen.size !== i.order.length) return 'CONSIST_ORDER';
|
||||
if (i.order.some((n) => n < 0 || n >= tray.consist.length)) return 'CONSIST_ORDER';
|
||||
/**
|
||||
* The engine may finish anywhere in the train, including with cars ahead of it (Jesse,
|
||||
* 2026-09-17). `engineAt` indexes the SORTED consist, so `consist.length` is legal and means
|
||||
* the engine on the tail with everything ahead of it — the shoving case a Small Yard exists to
|
||||
* set up. Refused outside that range rather than clamped: a clamp would silently build a
|
||||
* different train from the one the player asked for.
|
||||
*/
|
||||
if (i.engineAt !== undefined && (i.engineAt < 0 || i.engineAt > tray.consist.length)) {
|
||||
return 'CONSIST_ORDER';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1426,17 +1436,30 @@ function checkPlay(
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
|
||||
/**
|
||||
* NOT ON THE RUNNING TRACK ROW — AND NOT BOUNDED BY THE LIMITS EITHER. Jesse's call, both
|
||||
* halves.
|
||||
* NEITHER ON THE RUNNING TRACK ROW NOR OUTSIDE THE LIMITS — Jesse's call, both halves, the
|
||||
* second REVERSED on 2026-09-17 after a Day 3 playtest.
|
||||
*
|
||||
* A Modifier is not track (§9), so unlike a siding it may hang outside the Limits: a Facility
|
||||
* standing at the limit has three of its nine spots out there, and refusing them would make
|
||||
* the card unplayable exactly where the district ends. What it may NOT do is stand in the row
|
||||
* the Running Track grows along. Inside the Limits that row is always full, so this bites only
|
||||
* beyond the sign — which is the ground the main extends onto, and a Modifier parked there
|
||||
* would block your own sign from moving outward (§2.1) with nothing on screen to warn you.
|
||||
* It used to read the other way: a Modifier is not track (§9), so unlike a siding it could
|
||||
* hang outside the Limits, because a Facility standing at the limit has three of its nine
|
||||
* spots out there and refusing them would make the card unplayable exactly where the district
|
||||
* ends. What that argument missed is what the board then shows — Transmission Lines at (-2,4)
|
||||
* with the sign at column 3 — which reads as building outside your own territory, and §8.1 and
|
||||
* §10 both reason about what lies inside a player's Limits.
|
||||
*
|
||||
* THE UNPLAYABLE CASE WAS CHECKED ON THE REPORTED MOVE, not assumed away: the Power Plant was
|
||||
* at (-1,3) against a sign at column 3, and (-2,2) and (-2,3) were free, legal and inside. Six
|
||||
* of the nine spots survive a Facility at the limit, and the sign moves outward as the Running
|
||||
* Track grows (§2.1, Gap 4a), so the ground arrives with the district.
|
||||
*
|
||||
* The Running Track row stays barred for its own reason: inside the Limits that row is always
|
||||
* full, so it bit only beyond the sign, where a Modifier would block the sign from moving
|
||||
* outward with nothing on screen to warn you. That ground is now out of bounds anyway, which
|
||||
* makes this the narrower rule rather than a redundant one — the row is barred INSIDE the
|
||||
* Limits too, where a square can fall vacant if the main is rebuilt around it.
|
||||
*/
|
||||
if (placement.row === area.runningRow) return 'ON_RUNNING_TRACK';
|
||||
// §2.1 — a district's cards belong inside its own sign, Modifiers included since 2026-09-17.
|
||||
if (!withinLimits(area, placement)) return 'OUTSIDE_LIMITS';
|
||||
// One of a kind per Office Area, as with industries (Q4) — no district gets two Ice Houses.
|
||||
if (hasModifierInArea(area, card.kind.modifier)) return 'FACILITY_LOCKED';
|
||||
// §9 — a Modifier is not track. It must sit adjacent to a Facility THAT CAN HOST IT (one of
|
||||
@@ -1680,6 +1703,9 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
from,
|
||||
to: i.to,
|
||||
movesRemaining: turnOf(s, player).movesRemaining - 1,
|
||||
// "3 of 6" was written with the 6 hardcoded in the narrator, which is wrong on a night
|
||||
// Stage under Reduced Visibility, where a turn gets five. The turn knows; the event carries.
|
||||
movesAllowed: turnOf(s, player).movesAllowed,
|
||||
/**
|
||||
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND — AND A CURVE IS NOT A STRAIGHT.
|
||||
*
|
||||
@@ -1775,11 +1801,29 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
at: here,
|
||||
before: tray.consist.map((c) => ({ ...c })),
|
||||
after: i.order.map((n) => ({ ...tray.consist[n]! })),
|
||||
// Absent means the nose, which is what every sort did before 2026-09-17 — so an older save
|
||||
// replays to exactly the train it built.
|
||||
engineAt: i.engineAt ?? 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'switch.end':
|
||||
/**
|
||||
* The closing summary comes BEFORE `phaseEnded`, so the history reads as the turn ending rather
|
||||
* than as a postscript to it. Split out of the shared case below for that one line.
|
||||
*/
|
||||
case 'switch.end': {
|
||||
const turn = turnOf(s, player);
|
||||
const ended: GameEvent = {
|
||||
type: 'switchingEnded',
|
||||
player,
|
||||
movesUsed: turn.movesAllowed - turn.movesRemaining,
|
||||
movesAllowed: turn.movesAllowed,
|
||||
...(turn.lastMove ? { lastMove: turn.lastMove } : {}),
|
||||
};
|
||||
return [ended, { type: 'phaseEnded', player, phase: 'localOps' }];
|
||||
}
|
||||
|
||||
case 'draw.end':
|
||||
case 'freightAgent.end':
|
||||
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
|
||||
@@ -1956,18 +2000,41 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
];
|
||||
}
|
||||
|
||||
case 'newTrain.placeCar':
|
||||
/**
|
||||
* THE TRAIN'S NUMBER RIDES ALONG (playtest, 2026-09-16: "I did not see anything in the history
|
||||
* about making up train 10 and how each person added each car to it").
|
||||
*
|
||||
* It was all there — a MADE UP line and one line per car — but every one of those lines read
|
||||
* "the train being made up", so a player scanning the history for train 10 found nothing under
|
||||
* that name. The tray id is no use to a reader and the narrator has no state to look it up in,
|
||||
* so the number travels with the event, exactly as `owner` does on `trainArrived`.
|
||||
*/
|
||||
case 'newTrain.placeCar': {
|
||||
const placeTray = s.trays.get(i.trayId);
|
||||
return [
|
||||
{
|
||||
type: 'carPlacedOnTrain',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
stock: { type: i.carType, loaded: i.loaded },
|
||||
trainNumber: placeTray?.trainNumber ?? null,
|
||||
isExtra: placeTray?.trainIsExtra ?? false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'newTrain.passCar':
|
||||
return [{ type: 'carPassed', player, trayId: i.trayId }];
|
||||
case 'newTrain.passCar': {
|
||||
const passTray = s.trays.get(i.trayId);
|
||||
return [
|
||||
{
|
||||
type: 'carPassed',
|
||||
player,
|
||||
trayId: i.trayId,
|
||||
trainNumber: passTray?.trainNumber ?? null,
|
||||
isExtra: passTray?.trainIsExtra ?? false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'newTrain.secondSection':
|
||||
return [{ type: 'secondSectionOrdered', player, trainNumber: i.trainNumber }];
|
||||
@@ -2141,7 +2208,10 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
}
|
||||
// Only the player sitting in this district can be switching this tray, so the Moves come off
|
||||
// their turn. The event carries no player of its own.
|
||||
turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining;
|
||||
const mover = turnOf(s, playerAtSeat(s, seat));
|
||||
mover.movesRemaining = e.movesRemaining;
|
||||
// Where the crew was left, for the line that closes the turn — see `switchingEnded`.
|
||||
mover.lastMove = { trayId: e.trayId, to: e.to };
|
||||
|
||||
const area = areaAtSeat(s, seat);
|
||||
|
||||
@@ -2219,9 +2289,18 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'consistSorted': {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
tray.consist = e.after.map((c) => ({ ...c }));
|
||||
// A Small Yard re-makes the train, and putting the engine back on the nose is the whole reason
|
||||
// to use one: §8.2 will not let a train leave the Office with cars in front of its engine.
|
||||
tray.engineAt = 0;
|
||||
/**
|
||||
* WHERE THE SORT PUT THE ENGINE — 0 on every sort before 2026-09-17, and on most of them
|
||||
* since, because putting the engine back on the nose is what a Small Yard is usually for:
|
||||
* §8.2 will not let a train leave the Office with cars in front of its engine.
|
||||
*
|
||||
* It is no longer forced. The design source (`implications.md`) has always said a train here
|
||||
* "may sort itself into any order, INCLUDING cars ahead of the engine", against a v0.4.5 card
|
||||
* text that says the engine ends at the nose; Jesse settled it for the source. A numbered
|
||||
* train left nose-loaded is held at the Office by §8.2 until it sorts again — see
|
||||
* `departureRefusal`.
|
||||
*/
|
||||
tray.engineAt = e.engineAt;
|
||||
// "Spends one move in the yard" — the sort costs a Move.
|
||||
const sorter = turnOf(s, playerAtSeat(s, trayySeat(tray)));
|
||||
sorter.movesRemaining = Math.max(0, sorter.movesRemaining - 1);
|
||||
|
||||
+64
-4
@@ -46,7 +46,7 @@ export type GameEvent =
|
||||
* more than one legal route to `to`, so the history can say which one ran rather than leaving a
|
||||
* choice the player made invisible in their own log.
|
||||
*/
|
||||
| { type: 'trayMoved'; player: PlayerIndex; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
||||
| { type: 'trayMoved'; player: PlayerIndex; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; movesAllowed: number; facing?: 'n' | 's' | 'e' | 'w'; via?: GridCoord }
|
||||
| {
|
||||
type: 'carsCoupled';
|
||||
player: PlayerIndex;
|
||||
@@ -82,7 +82,39 @@ export type GameEvent =
|
||||
recoupled?: { at: GridCoord; stock: RollingStock[] };
|
||||
}
|
||||
| { type: 'carsDropped'; player: PlayerIndex; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
||||
| { type: 'consistSorted'; player: PlayerIndex; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
| {
|
||||
type: 'consistSorted';
|
||||
player: PlayerIndex;
|
||||
trayId: TrayId;
|
||||
at: GridCoord;
|
||||
before: RollingStock[];
|
||||
after: RollingStock[];
|
||||
/**
|
||||
* Where the engine ends up in `after`, counted as an index into it — 0 is the nose.
|
||||
*
|
||||
* The Small Yard used to put the engine back on the front unconditionally, which is what the
|
||||
* v0.4.5 card text says ("reorder its entire consist and put the engine at the nose").
|
||||
* `implications.md` records the design source saying the opposite — "may sort itself into any
|
||||
* order, INCLUDING cars ahead of the engine" — and Jesse settled it that way on 2026-09-17.
|
||||
*/
|
||||
engineAt: number;
|
||||
}
|
||||
/**
|
||||
* A player finished their switching turn: what it cost, and where the crew was left.
|
||||
*
|
||||
* The history panel keeps a switching turn's FIRST move and drops the ones in the middle, so the
|
||||
* closing line is where "and it ended up here" has to come from. It cannot be recovered by
|
||||
* revealing the last `trayMoved` after the fact: the log streams to clients as it is written
|
||||
* (`server/session.ts` § linesSince), and nobody knows a move was the last one until the turn is
|
||||
* already over and that line has been sent.
|
||||
*/
|
||||
| {
|
||||
type: 'switchingEnded';
|
||||
player: PlayerIndex;
|
||||
movesUsed: number;
|
||||
movesAllowed: number;
|
||||
lastMove?: { trayId: TrayId; to: GridCoord };
|
||||
}
|
||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
||||
/**
|
||||
* §6.2 — the Home Office deck ran out, so the Salvage Yard and all three Department decks were
|
||||
@@ -195,8 +227,36 @@ export type GameEvent =
|
||||
at: ExtraStart;
|
||||
direction: Direction;
|
||||
}
|
||||
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
|
||||
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
|
||||
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock; trainNumber: number | null; isExtra: boolean }
|
||||
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId; trainNumber: number | null; isExtra: boolean }
|
||||
/**
|
||||
* A train was made up and the round could give it NOTHING THE CARD CALLS FOR — the Division Yard
|
||||
* holds no car of a category it still wants (§7, §8.2 "may depart with fewer").
|
||||
*
|
||||
* ITS OWN EVENT BECAUSE THE SILENCE WAS THE BUG (playtest, 2026-09-16: "train 5, the sparrow, has
|
||||
* no coaches, which seems strange"). `trainNeedingCars` returns null in exactly this case, so the
|
||||
* phase never stops, nobody is asked for a car, and the only trace was a MADE UP line promising
|
||||
* "now taking cars" with nothing after it. The train then ran the whole Division empty.
|
||||
*
|
||||
* CARRIES WHY, not just that. The shortage is a standing condition rather than a moment — §2.2
|
||||
* returns the Classification Yard only when the Division Yard runs bare — so the counts that
|
||||
* explain it have to travel with the event: what is still wanted, how many such cars are waiting
|
||||
* in Classification, and how far the Division Yard is from empty.
|
||||
*/
|
||||
| {
|
||||
type: 'makeUpShort';
|
||||
trainNumber: number;
|
||||
isExtra: boolean;
|
||||
/** How many cars it got, out of what the card calls for. */
|
||||
placed: number;
|
||||
wanted: number;
|
||||
/** The categories the card still wants and the Division Yard cannot supply. */
|
||||
missing: ('freight' | 'coach' | 'caboose')[];
|
||||
/** Cars of those categories sitting in the Classification Yard. */
|
||||
waiting: number;
|
||||
/** §2.2 — Classification comes back only when this reaches zero. */
|
||||
divisionYardHolds: number;
|
||||
}
|
||||
| { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number }
|
||||
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
|
||||
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
|
||||
|
||||
+13
-1
@@ -47,7 +47,19 @@ export type Intent =
|
||||
* order, including cars in front of the engine". This is the designed answer to §A.3's
|
||||
* come-off-in-seated-order constraint, which is what makes facing-point work possible.
|
||||
*/
|
||||
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[] }
|
||||
/**
|
||||
* §Enhancements, Small Yard — one Move to re-make a train standing on the yard.
|
||||
*
|
||||
* `order` is a permutation of the current consist, nose first. `engineAt` is where the LOCOMOTIVE
|
||||
* ends up in it: 0 puts it back on the front, which is what the v0.4.5 card text describes and
|
||||
* what this action did unconditionally until 2026-09-17. `implications.md` records the design
|
||||
* source saying a train here "may sort itself into any order, including cars ahead of the engine",
|
||||
* and Jesse ruled that way — so it is a number now, and a train left nose-loaded is one §8.2 will
|
||||
* not let out of the Office until it is sorted again.
|
||||
*
|
||||
* Optional, defaulting to 0, so every save written before this replays exactly as it did.
|
||||
*/
|
||||
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[]; engineAt?: number }
|
||||
| { type: 'switch.end' }
|
||||
// -- draw (§6.2)
|
||||
| { type: 'draw.fromHomeOffice' }
|
||||
|
||||
+68
-6
@@ -91,17 +91,79 @@ function switchCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
// that shoved a cut, and therefore the only way an engine buried mid-train reaches an end.
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n, fromNose: true });
|
||||
}
|
||||
// Small Yard: enumerating every permutation would explode, so offer the useful ones —
|
||||
// bringing each car to the droppable end, plus a full reversal. `check` validates any order,
|
||||
// so a UI may submit an arbitrary permutation.
|
||||
/**
|
||||
* Small Yard: enumerating every permutation would explode, so offer the useful ones — bringing
|
||||
* each car to the droppable end, plus a full reversal. `check` validates any order, so a UI may
|
||||
* submit an arbitrary permutation.
|
||||
*
|
||||
* NOTHING THAT RE-ORDERS NOTHING. Bringing the LAST car to the end is the identity, and a
|
||||
* two-car train's reversal repeats its only real option — so the menu carried a move that spent
|
||||
* one of six Moves to leave the train exactly as it was, beside a duplicate of the move next to
|
||||
* it. Both were invisible while the labels were index lists (playtest, 2026-09-17); both are
|
||||
* plainly wrong once the label reads as a train. Filtered by the ORDER rather than by the case
|
||||
* that produced it, so a new generator cannot reintroduce either.
|
||||
*/
|
||||
const n = tray.consist.length;
|
||||
const identity = [...Array(n).keys()];
|
||||
if (n > 1) {
|
||||
const orders: number[][] = [];
|
||||
for (let k = 0; k < n; k++) {
|
||||
const order = [...Array(n).keys()].filter((x) => x !== k);
|
||||
const order = identity.filter((x) => x !== k);
|
||||
order.push(k);
|
||||
out.push({ type: 'switch.sortConsist', trayId, order });
|
||||
orders.push(order);
|
||||
}
|
||||
orders.push([...identity].reverse());
|
||||
// Nothing that re-orders nothing: the current train is the one thing on offer that costs a
|
||||
// Move and changes the board not at all. Keyed by (order, engine position) together, since
|
||||
// since 2026-09-17 the same car order at a different engine position is a different train.
|
||||
const seen = new Set<string>([`${identity.join(',')}|${tray.engineAt}`]);
|
||||
const offer = (order: number[], engineAt: number): void => {
|
||||
const key = `${order.join(',')}|${engineAt}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push({ type: 'switch.sortConsist', trayId, order, engineAt });
|
||||
};
|
||||
// The car orders, each leaving the engine on the nose — the Small Yard's ordinary use.
|
||||
for (const order of orders) offer(order, 0);
|
||||
/**
|
||||
* A MADE-UP ORDER IS ALWAYS AMONG THESE, which is worth saying because it looks as though it
|
||||
* might not be (Jesse, 2026-09-17: "trains with a caboose have to offer the caboose at the
|
||||
* back").
|
||||
*
|
||||
* A yard sort serves two errands — pulling one car out to an end so it can be spotted, and
|
||||
* putting the train back together to leave — and the orders above are written for the first.
|
||||
* They cover the second as a by-product: "bring car k to the tail" is enumerated for EVERY car,
|
||||
* so bringing the CABOOSE to the tail is always one of them, and with the engine on the nose
|
||||
* that is a train §8.2 will let out of the Office.
|
||||
*
|
||||
* An explicit "make it up to leave" option was written here and deleted: it produced exactly
|
||||
* the k-is-the-caboose order and was dropped by the dedupe every time. The one case where no
|
||||
* made-up order appears is a train that is ALREADY made up, where such an option would be the
|
||||
* identity — and the labels say which is which, so a player can see that every offer would
|
||||
* break a train that is currently fit to run.
|
||||
*/
|
||||
}
|
||||
/**
|
||||
* WHERE THE ENGINE GOES, as its own short list rather than multiplied through the one above
|
||||
* (Jesse's call, 2026-09-17: "a separate engine control").
|
||||
*
|
||||
* Offering every car order at every engine position is the honest enumeration and it is
|
||||
* unreadable: a four-car consist would go from four options to twenty, which is the labelling
|
||||
* problem that prompted all of this. So the engine positions are offered against the consist AS
|
||||
* IT STANDS — pick an order, or pick where the engine sits, each one Move. A player who wants
|
||||
* both spends two, which is the same price the yard charges for any second sort.
|
||||
*
|
||||
* OFFERED FOR A ONE-CAR TRAIN TOO, unlike the car orders: a single car ahead of the engine or
|
||||
* behind it is exactly the difference between shoving it into a facing industry and pulling it.
|
||||
*/
|
||||
if (n >= 1) {
|
||||
const seenEngine = new Set<string>([`${identity.join(',')}|${tray.engineAt}`]);
|
||||
for (let k = 0; k <= n; k++) {
|
||||
const key = `${identity.join(',')}|${k}`;
|
||||
if (seenEngine.has(key)) continue;
|
||||
seenEngine.add(key);
|
||||
out.push({ type: 'switch.sortConsist', trayId, order: identity, engineAt: k });
|
||||
}
|
||||
out.push({ type: 'switch.sortConsist', trayId, order: [...Array(n).keys()].reverse() });
|
||||
}
|
||||
}
|
||||
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
|
||||
|
||||
@@ -875,6 +875,25 @@ export type FinalReport = {
|
||||
export type TurnState = {
|
||||
option: 'switch' | 'draw' | 'freightAgent' | null;
|
||||
movesRemaining: number;
|
||||
/**
|
||||
* What `movesRemaining` started at this Stage — six, or five under Reduced Visibility at night.
|
||||
*
|
||||
* CARRIED RATHER THAN ASSUMED. Every reader of `movesRemaining` that wanted to say "3 of 6" had
|
||||
* hardcoded the 6, which is simply wrong on a night Stage, and the only other way to recover it is
|
||||
* to re-derive `movesForStage` outside the phase driver that owns it. It also makes "is this the
|
||||
* FIRST move of the turn?" a comparison rather than a guess, which is what the history panel needs
|
||||
* to keep the opening move of a switching turn and drop the ones in the middle.
|
||||
*/
|
||||
movesAllowed: number;
|
||||
/**
|
||||
* The last square this player's crew moved to this Stage, and which crew it was.
|
||||
*
|
||||
* Switching ends with a summary line, and "where did the train end up" is the half of it a player
|
||||
* actually wants. It cannot be recovered from the log: the line naming the last move is written
|
||||
* before anyone knows it was the last, and the log streams to clients as it is written, so a line
|
||||
* already sent cannot be revised afterwards.
|
||||
*/
|
||||
lastMove?: { trayId: TrayId; to: GridCoord };
|
||||
drawnThisTurn: boolean;
|
||||
freightAgentUsed: boolean;
|
||||
/**
|
||||
@@ -1057,6 +1076,7 @@ export function freshTurn(moves: number): TurnState {
|
||||
return {
|
||||
option: null,
|
||||
movesRemaining: moves,
|
||||
movesAllowed: moves,
|
||||
drawnThisTurn: false,
|
||||
freightAgentUsed: false,
|
||||
freightWorked: {},
|
||||
|
||||
+13
-4
@@ -684,10 +684,19 @@ export function carriesThroughTrack(card: TrackCard): boolean {
|
||||
* buildable column and break §11.3's promise that both Secondary rows, and the nine-spot Modifier
|
||||
* neighbourhood, are usable from the first Stage.
|
||||
*
|
||||
* MODIFIERS ARE NOT SUBJECT TO THIS, and are not track: §9 places one on any of the nine spots
|
||||
* around a Facility, and a Facility standing at the limit has three of its nine outside them.
|
||||
* Jesse's call. `check` bars them from the Running Track ROW instead, which is the ground the main
|
||||
* grows onto.
|
||||
* MODIFIERS ARE SUBJECT TO THIS TOO, since 2026-09-17 — REVERSING an earlier call of Jesse's that
|
||||
* exempted them. The exemption reasoned that §9 places a Modifier on any of the nine spots around a
|
||||
* Facility, so a Facility standing at the limit has three of its nine outside them and bounding the
|
||||
* card would make it unplayable exactly where a district ends. Play showed the cost of that the
|
||||
* other way round: a Transmission Lines card went down at (-2,4) with the sign at column 3, which
|
||||
* reads at the table as building outside your own territory, and §8.1 and §10 both reason about
|
||||
* what is inside a player's Limits.
|
||||
*
|
||||
* THE FEARED CASE DID NOT ARISE, and was measured on the move that prompted the change rather than
|
||||
* argued: the Power Plant sat at (-1,3) against a sign at 3, and (-2,2) and (-2,3) were both free,
|
||||
* legal and inside. A Facility at the limit keeps six of its nine spots, and the Limits move outward
|
||||
* as the Running Track grows (§2.1, Gap 4a), so the ground for a Modifier arrives with the district.
|
||||
* `check` bars them from the Running Track ROW as well, which is the ground the main grows onto.
|
||||
*/
|
||||
export function withinLimits(area: OfficeArea, coord: GridCoord): boolean {
|
||||
return coord.col >= area.limitsWest.col && coord.col <= area.limitsEast.col;
|
||||
|
||||
+110
-9
@@ -20,6 +20,8 @@ import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
|
||||
import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, freightRuleSpentHere, isFreight, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import { badlyMadeUp } from '../engine/advance.ts';
|
||||
import type { CrewTray } from '../engine/state.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small formatters
|
||||
@@ -50,6 +52,23 @@ export function carLabel(c: RollingStock, homeSeat?: SeatIndex): string {
|
||||
return homeSeat !== undefined && c.origin === homeSeat ? `${label} (loaded here)` : label;
|
||||
}
|
||||
|
||||
/**
|
||||
* "a loaded boxcar", "an empty tank" — the article the word actually takes.
|
||||
*
|
||||
* The make-up line hard-coded "a" and produced "a empty tank" at the table. Vowel-initial is the
|
||||
* whole rule here: every car word is ordinary English ('empty', 'loaded', and the car types), so
|
||||
* there is no 'an hour' case to special-case and inventing one would be the more fragile choice.
|
||||
*/
|
||||
export function indefinite(label: string): string {
|
||||
return `${/^[aeiou]/i.test(label) ? 'an' : 'a'} ${label}`;
|
||||
}
|
||||
|
||||
/** "Train 10" / "Extra X18" — one spelling of a train's name for every line that mentions one. */
|
||||
export function trainLabel(trainNumber: number | null, isExtra: boolean): string {
|
||||
if (trainNumber === null) return 'the local crew';
|
||||
return isExtra ? `Extra X${trainNumber}` : `Train ${trainNumber}`;
|
||||
}
|
||||
|
||||
export function carsLabel(cars: RollingStock[]): string {
|
||||
if (cars.length === 0) return 'nothing';
|
||||
return cars.map(carLabel).join(', ');
|
||||
@@ -108,11 +127,22 @@ export type NarrateContext = {
|
||||
* events has no roster, and "Player 2" is a truthful fallback rather than a broken one.
|
||||
*/
|
||||
playerName?: (player: PlayerIndex) => string;
|
||||
/**
|
||||
* Names the Facility standing on one of a player's squares, or null where there is none.
|
||||
*
|
||||
* Switching lines used to give the bare coordinate — "Set out a loaded hopper at (-1,1)" — which
|
||||
* is the grid's own notation and means nothing at a table where people are looking at cards. The
|
||||
* industry is the whole point of the move, so it is what the line should say.
|
||||
*/
|
||||
facilityAt?: (player: PlayerIndex, at: GridCoord) => string | null;
|
||||
};
|
||||
|
||||
export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
const card = (id: string): string => ctx.cardName?.(id) ?? 'a card';
|
||||
const train = (id: TrayId): string => ctx.trainName?.(id) ?? String(id);
|
||||
// The industry on a square when there is one, and the coordinate when there is not — a crew works
|
||||
// plain track too, and "at nowhere" would be worse than the notation.
|
||||
const place = (player: PlayerIndex, c: GridCoord): string => ctx.facilityAt?.(player, c) ?? at(c);
|
||||
|
||||
switch (e.type) {
|
||||
// -- clock
|
||||
@@ -167,7 +197,11 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.to,
|
||||
text: `Moved ${train(e.trayId)} ${at(e.from)} → ${at(e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
|
||||
// NO TUTORIAL TAIL. "The crew chip on the grid carries the whole train with it" was appended
|
||||
// to EVERY move — six times a turn, and the opener (`localOpsOptionChosen`) already says it
|
||||
// once. It also pushed the useful half of the line out of the caption row, which shows one
|
||||
// step at a time and is the place a player reads a move as it happens.
|
||||
text: `Moved ${train(e.trayId)} ${at(e.from)} → ${place(e.player, e.to)}${e.via ? ` via ${at(e.via)}` : ''} — ${e.movesRemaining} of ${e.movesAllowed} Moves left`,
|
||||
};
|
||||
case 'carsCoupled': {
|
||||
/**
|
||||
@@ -179,22 +213,59 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
const own = e.recoupled?.stock.length ?? 0;
|
||||
const found = e.stock.length - own;
|
||||
const parts: string[] = [];
|
||||
if (own > 0) parts.push(`picked its own ${carsLabel(e.recoupled!.stock)} back up off ${at(e.recoupled!.at)} on the way out`);
|
||||
if (found > 0) parts.push(`coupled ${carsLabel(e.stock.slice(own))} standing on the line`);
|
||||
// `a loaded tank` for one, a bare list for several — "took loaded tank" reads as a telegram.
|
||||
const some = (cars: RollingStock[]): string =>
|
||||
cars.length === 1 ? indefinite(carLabel(cars[0]!)) : carsLabel(cars);
|
||||
if (own > 0) parts.push(`picked its own ${some(e.recoupled!.stock)} back up off ${at(e.recoupled!.at)} on the way out`);
|
||||
if (found > 0) parts.push(`took ${some(e.stock.slice(own))} standing there`);
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text:
|
||||
`Coupled ${e.stock.length} car(s) at ${at(e.at)} ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
|
||||
// "1 car(s)" was the plural of a machine. The count is already implied by the cars named
|
||||
// in `parts`, so the sentence leads with where and which end instead.
|
||||
`Coupled at ${place(e.player, e.at)}, ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
|
||||
` — ${parts.join(', and ')}`,
|
||||
};
|
||||
}
|
||||
case 'consistSorted':
|
||||
case 'consistSorted': {
|
||||
/**
|
||||
* WHERE THE ENGINE ENDED UP, and whether the train can still run.
|
||||
*
|
||||
* ASKED OF `badlyMadeUp` RATHER THAN RE-DECIDED HERE, which matters because the obvious guess
|
||||
* is wrong: §8.2 is enforced direction-free, so a train with its WHOLE consist ahead of the
|
||||
* engine is a pushing train and perfectly fit to leave. What it may not be is broken-backed,
|
||||
* with the engine buried among its own cars. A copy of that rule in the narrator would have
|
||||
* told a player their pushing train was stranded when it was not.
|
||||
*/
|
||||
const ahead = e.engineAt;
|
||||
const unfit = badlyMadeUp({ consist: e.after, engineAt: e.engineAt } as CrewTray);
|
||||
const where =
|
||||
ahead === 0
|
||||
? 'so the right car is now on the end and can be spotted'
|
||||
: unfit === null
|
||||
? `with the whole consist AHEAD of the engine — it runs as a pushing train`
|
||||
: `with ${ahead} car${ahead === 1 ? '' : 's'} ahead of the engine — ${unfit}, so it is held at the Office until it is sorted again (§8.2)`;
|
||||
return {
|
||||
tone: 'good',
|
||||
where: e.at,
|
||||
text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
|
||||
text: `Used the SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], ${where}`,
|
||||
};
|
||||
}
|
||||
case 'switchingEnded': {
|
||||
/**
|
||||
* The line that closes a switching turn, and the only one the history keeps from the middle of
|
||||
* it: what it cost, and where the crew was left standing.
|
||||
*/
|
||||
const used = `${e.movesUsed} of ${e.movesAllowed} Move${e.movesAllowed === 1 ? '' : 's'} used`;
|
||||
if (e.movesUsed === 0) return { tone: 'quiet', text: 'Finished switching without moving a car' };
|
||||
if (!e.lastMove) return { tone: 'plain', text: `Finished switching — ${used}` };
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.lastMove.to,
|
||||
text: `Finished switching — ${used}, leaving ${train(e.lastMove.trayId)} at ${place(e.player, e.lastMove.to)}`,
|
||||
};
|
||||
}
|
||||
case 'carsDropped':
|
||||
// WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows:
|
||||
// the train may pull away from cars set out behind it and must couple back up to cars set out
|
||||
@@ -203,7 +274,7 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text:
|
||||
`Set out ${carsLabel(e.stock)} at ${at(e.at)}, off the ${e.fromNose ? 'NOSE — ahead of the engine, so pulling forward will couple them again' : 'TAIL — behind the engine, so it may pull away and leave them'}`,
|
||||
`Set out ${carsLabel(e.stock)} at ${place(e.player, e.at)}, off the ${e.fromNose ? 'NOSE — ahead of the engine, so pulling forward will couple them again' : 'TAIL — behind the engine, so it may pull away and leave them'}`,
|
||||
};
|
||||
|
||||
// -- cards
|
||||
@@ -427,9 +498,39 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
};
|
||||
}
|
||||
case 'carPlacedOnTrain':
|
||||
return { tone: 'plain', text: `Added a ${carLabel(e.stock)} to the train being made up` };
|
||||
// NAMES THE TRAIN. "the train being made up" was true and useless: a player looking back for
|
||||
// what happened to train 10 found four lines that never said 10 (playtest, 2026-09-16).
|
||||
return {
|
||||
tone: 'plain',
|
||||
text: `Added ${indefinite(carLabel(e.stock))} to ${trainLabel(e.trainNumber, e.isExtra)}`,
|
||||
};
|
||||
case 'carPassed':
|
||||
return { tone: 'quiet', text: 'Passed — no suitable car in the Division Yard' };
|
||||
return {
|
||||
tone: 'quiet',
|
||||
text: `Passed on ${trainLabel(e.trainNumber, e.isExtra)} — no suitable car in the Division Yard`,
|
||||
};
|
||||
case 'makeUpShort': {
|
||||
// What it wanted, in the words the card uses, so the line can be checked against the card.
|
||||
const names: Record<'freight' | 'coach' | 'caboose', string> = {
|
||||
freight: 'freight car',
|
||||
coach: 'coach',
|
||||
caboose: 'caboose',
|
||||
};
|
||||
const wants = e.missing.map((m) => names[m]).join(' or ');
|
||||
const got = e.placed === 0 ? 'NO CARS AT ALL' : `only ${e.placed} of the ${e.wanted} its card calls for`;
|
||||
// §2.2 is the whole explanation and it is not guessable from the board: the cars are visible
|
||||
// in the Classification Yard, and why they will not come back is not.
|
||||
const why =
|
||||
e.waiting > 0
|
||||
? ` ${e.waiting} sit in the Classification Yard, which comes back only when the Division Yard is bare — and it still holds ${e.divisionYardHolds} cars.`
|
||||
: ' There are none in the Classification Yard either.';
|
||||
return {
|
||||
tone: 'bad',
|
||||
text:
|
||||
`${trainLabel(e.trainNumber, e.isExtra).toUpperCase()} WAS MADE UP WITH ${got} — the ` +
|
||||
`Division Yard holds no ${wants} it can take, so nobody was asked for one.${why}`,
|
||||
};
|
||||
}
|
||||
case 'dispatchBonusUsed':
|
||||
return {
|
||||
tone: 'good',
|
||||
|
||||
+60
-3
@@ -10,7 +10,7 @@
|
||||
* drift into two different pictures of the same board.
|
||||
*/
|
||||
|
||||
import { isExpedited, regionOfTransit } from '../engine/advance.ts';
|
||||
import { badlyMadeUp, isExpedited, regionOfTransit } from '../engine/advance.ts';
|
||||
import {
|
||||
areaAtSeat,
|
||||
areaOf,
|
||||
@@ -1036,8 +1036,65 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
const end = i.fromNose ? 'off the front' : 'off the back';
|
||||
return `set out ${carsLabel(cut)} ${end}`;
|
||||
}
|
||||
case 'switch.sortConsist':
|
||||
return `re-order consist [${i.order.join(',')}]`;
|
||||
case 'switch.sortConsist': {
|
||||
/**
|
||||
* THE TRAIN IT WOULD MAKE, DRAWN THE WAY THE BOARD DRAWS IT.
|
||||
*
|
||||
* This read `re-order consist [1,2,3,0]` — the engine's own array indices offered to a person
|
||||
* — and the option Jesse wanted was the first of five and unidentifiable (playtest,
|
||||
* 2026-09-17). Naming the cars fixed that and left a second ambiguity he caught immediately:
|
||||
* a list "front to back" means nothing at a table looking at a map, because which end is the
|
||||
* front depends on which way the train is pointed.
|
||||
*
|
||||
* SO IT IS LAID OUT WEST TO EAST, exactly as `board-svg.ts` lays the crew strip: the consist
|
||||
* is stored nose first, and a train facing EAST is reversed so its nose lands at the east end
|
||||
* where it actually is. The engine is the same ◀ / ▶ arrow the board uses, seated where it
|
||||
* will be, so "ahead of the engine" and "behind the engine" are read off the picture rather
|
||||
* than asserted in words — and the button and the board cannot disagree.
|
||||
*/
|
||||
const sorting = s.trays.get(i.trayId);
|
||||
if (!sorting) return `re-order consist [${i.order.join(',')}]`;
|
||||
const after = i.order.map((n) => sorting.consist[n]!).filter((c) => c !== undefined);
|
||||
const engineAt = i.engineAt ?? 0;
|
||||
const facing = railFacingOf(sorting);
|
||||
|
||||
const items = after.map((c) => carLabel(c));
|
||||
items.splice(engineAt, 0, facing === 'w' ? '◀ ENGINE' : 'ENGINE ▶');
|
||||
// West on the left, like the map and like the crew strip on the board.
|
||||
const strip = (facing === 'e' ? [...items].reverse() : items).join(' · ');
|
||||
|
||||
/**
|
||||
* WHAT IT WOULD MEAN, from §8.2's own predicate rather than a copy of it: a train with its
|
||||
* whole consist ahead of the engine is a PUSHING train and fit to run, a buried engine is not,
|
||||
* and a caboose has to ride at the end away from the engine. Numbered trains only — a local
|
||||
* crew has no card and never departs, so a departure verdict on one is noise.
|
||||
*/
|
||||
const unfit =
|
||||
sorting.trainNumber === null
|
||||
? null
|
||||
: badlyMadeUp({ ...sorting, consist: after, engineAt });
|
||||
// `badlyMadeUp` leads with "not made up — ", which reads as a stutter in front of HELD. The
|
||||
// reason after it is the part worth showing, so the prefix comes off.
|
||||
const because = unfit?.replace(/^not made up — /, '') ?? '';
|
||||
const verdict =
|
||||
sorting.trainNumber === null
|
||||
? ''
|
||||
: unfit === null
|
||||
? ' · MADE UP, ready to leave'
|
||||
: ` · HELD at the Office: ${because}`;
|
||||
|
||||
// A sort that only moves the engine says which errand it is running, rather than reprinting a
|
||||
// car order that has not changed.
|
||||
const sameOrder = i.order.every((n, at) => n === at);
|
||||
const lead = sameOrder
|
||||
? engineAt === 0
|
||||
? 'pull the engine back to the front'
|
||||
: engineAt === after.length
|
||||
? 'put the whole consist ahead of the engine'
|
||||
: `move the engine behind ${engineAt} car${engineAt === 1 ? '' : 's'}`
|
||||
: 're-order';
|
||||
return `${lead} — west to east: ${strip}${verdict}`;
|
||||
}
|
||||
case 'freightAgent.stockOutbound': {
|
||||
/**
|
||||
* "stock a coach at (0, 0)" reads as putting a CAR on the track, and was reported as exactly
|
||||
|
||||
+81
-2
@@ -29,7 +29,7 @@ import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
import { createGame } from '../engine/setup.ts';
|
||||
import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
||||
import type { CardId, GameConfig, GameState, GridCoord, PlayerIndex } from '../engine/state.ts';
|
||||
import { overHandLimit as overHandLimitOf } from '../engine/state.ts';
|
||||
import { playerAtSeat } from '../engine/state.ts';
|
||||
import { cuesFor, narrate } from '../sim/narrate.ts';
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
LEGACY_HOUSE_RULES,
|
||||
collectiveRevenueFloor,
|
||||
houseRules,
|
||||
industryProfile,
|
||||
mainlineProfile,
|
||||
trainProfile,
|
||||
} from '../engine/content.ts';
|
||||
@@ -1269,6 +1270,81 @@ function uncapitalise(text: string): string {
|
||||
return /^[A-Z][a-z]/.test(text) ? text.charAt(0).toLowerCase() + text.slice(1) : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Facility on one of a player's squares, or null — for naming the place a switching line is
|
||||
* about. Shared by the narrator and the filter below, so both agree on what counts as an industry.
|
||||
*/
|
||||
function facilityOn(game: Game, player: PlayerIndex, at: GridCoord): string | null {
|
||||
const card = areaOf(game.state, player).grid.get(`${at.row},${at.col}`);
|
||||
const f = card?.facility;
|
||||
// The name printed on the card, not the internal key: `industryProfile` is the one place that
|
||||
// knows "grocersWarehouse" reads as "Grocer's Warehouse".
|
||||
if (f) return f.subtype === 'office' ? 'the Office' : `the ${industryProfile(f.subtype).name}`;
|
||||
/**
|
||||
* A SMALL YARD IS A PLACE TOO, though it is an enhancement on a plain card rather than a Facility.
|
||||
*
|
||||
* It is the one square in a district a crew goes to ON PURPOSE without working an industry — the
|
||||
* whole point of the trip is to arrive there and re-make the train — so "leaving Train 10 at
|
||||
* (-1,1)" was the one line most in need of a name. Only this enhancement: the others change what a
|
||||
* square DOES without being somewhere a player aims a crew at.
|
||||
*/
|
||||
return card?.enhancements.includes('smallYard') ? 'the Small Yard' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HOW MUCH SWITCHING REACHES THE HISTORY PANEL (Jesse, 2026-09-17).
|
||||
*
|
||||
* All of it did. A six-Move turn wrote a line per move — "Moved Train 10 (0,3) → (-1,-3) — 4 of 6
|
||||
* Moves left" — plus one per mandatory coupling, so two players shunting filled the panel with
|
||||
* coordinates and pushed everything else off the top. His ruling, asked as a question from the
|
||||
* table: a line saying somebody switched, the cars they set out at or picked up from an INDUSTRY,
|
||||
* and the Small Yard sort. Not every move, and not every coupling.
|
||||
*
|
||||
* WHAT STAYS, and why each one earns its line: `localOpsOptionChosen` already says who is switching
|
||||
* and is left alone; work at an industry is the point of switching and changes what can be loaded
|
||||
* next; and `consistSorted` spends a Move and changes what the train can do. A plain move along
|
||||
* one's own track changes nothing anybody needs to read back.
|
||||
*
|
||||
* THE LINE IS STILL WRITTEN, MARKED `trace`, AND THAT IS NOT A DETAIL. Dropping these events on the
|
||||
* floor was the first attempt and the suite caught it: `dwellForStep` gives a step NO dwell when it
|
||||
* produced no narration, so a switching move with no line became a silent step and the board stopped
|
||||
* replaying switching altogether — it would have snapped through the very thing v0.8.0 was built to
|
||||
* let the table watch. The line still rides with its display step and still captions the board as
|
||||
* the move goes up; only the history panel skips it.
|
||||
*
|
||||
* THE REPLAY VIEWER IS UNAFFECTED for the same reason, and it renders through `narrate` directly.
|
||||
*/
|
||||
function inHistory(game: Game, e: GameEvent): boolean {
|
||||
const industry = (player: PlayerIndex, ...coords: GridCoord[]): boolean =>
|
||||
coords.some((c) => facilityOn(game, player, c) !== null);
|
||||
switch (e.type) {
|
||||
/**
|
||||
* THE FIRST MOVE OF A TURN IS KEPT (Jesse, 2026-09-17: "also keep the first and last move").
|
||||
*
|
||||
* It says a crew set off and from where, which is the half of "somebody switched" that the
|
||||
* opener does not carry. The LAST move cannot be kept the same way — nothing knows a move was
|
||||
* the last until the turn is over, and by then the line has already been written and streamed to
|
||||
* every client (`server/session.ts` § linesSince), so it cannot be revised. `switchingEnded`
|
||||
* carries it instead, as the line that closes the turn.
|
||||
*
|
||||
* Recognised by the MOVE COUNT rather than by tracking state: the first move of a turn is the
|
||||
* one that leaves `movesAllowed - 1` behind it, which the event now carries so this holds on a
|
||||
* five-Move night Stage too.
|
||||
*/
|
||||
case 'trayMoved':
|
||||
return e.movesRemaining === e.movesAllowed - 1;
|
||||
// Coupling is mandatory when a crew runs over cars (§A.4), so most of these happen to a player
|
||||
// rather than being chosen. The ones worth reading are where cars left or joined an industry —
|
||||
// `from` names the cards the cars were actually lifted off, which is where they had been spotted.
|
||||
case 'carsCoupled':
|
||||
return industry(e.player, e.at, ...e.from);
|
||||
case 'carsDropped':
|
||||
return industry(e.player, e.at);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
|
||||
const who = actor === null ? null : (game.state.players[actor]?.name ?? null);
|
||||
for (const e of events) {
|
||||
@@ -1277,6 +1353,7 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
const n = narrate(e, {
|
||||
cardName: (id) => cardName(game.state, id),
|
||||
trainName: (id) => trainName(game.state, id),
|
||||
facilityAt: (player, at) => facilityOn(game, player, at),
|
||||
// Whose district a train reached is not the actor — the Mainline Phase has none — so the
|
||||
// narration resolves the name itself rather than being prefixed with one by the code below.
|
||||
// NO NUMBER IN THE FALLBACK. This is a PLAYER index, and a player is not a seat — seats rotate
|
||||
@@ -1320,7 +1397,9 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
: mine
|
||||
? `Player ${who} ${uncapitalise(said)}`
|
||||
: said;
|
||||
game.log.push({ text, tone: mine || ruling ? 'act' : n.tone });
|
||||
// `trace` is a tone the history panel does not draw — see `inHistory`. The line exists so the
|
||||
// step that caused it has narration to caption the board with, and a dwell to be watched for.
|
||||
game.log.push({ text, tone: inHistory(game, e) ? (mine || ruling ? 'act' : n.tone) : 'trace' });
|
||||
|
||||
}
|
||||
game.cues.push(...cuesFor(events));
|
||||
|
||||
+59
-3
@@ -1807,7 +1807,17 @@ function render(): void {
|
||||
* steps still queued are withheld, and each appears as its step goes up.
|
||||
*/
|
||||
const heldBack = stepQueue.pendingLines();
|
||||
const allLines = heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines();
|
||||
/**
|
||||
* HELD BACK FIRST, THEN THE TRACE LINES DROPPED — the order matters.
|
||||
*
|
||||
* `pendingLines` counts lines in the log, including the `trace` ones a switching move writes for
|
||||
* its caption (`web/game.ts` § inHistory), so the tail has to be cut off the RAW list or the
|
||||
* arithmetic slips and the panel runs ahead of the board. Filtering afterwards only decides what
|
||||
* is drawn.
|
||||
*/
|
||||
const allLines = (heldBack > 0 ? session.lines().slice(0, -heldBack) : session.lines()).filter(
|
||||
(l) => l.tone !== 'trace',
|
||||
);
|
||||
/**
|
||||
* NINETY LINES, IN THE SAME BOX (Jesse, 2026-09-16: "increase to 90, keep the box the same size").
|
||||
*
|
||||
@@ -1955,12 +1965,35 @@ function renderYards(f: Frame): void {
|
||||
$('divtot').textContent = `${yards.divisionTotal} cars`;
|
||||
$('clstot').textContent = `${yards.classificationTotal} cars`;
|
||||
|
||||
// The one thing worth calling out: the yard about to turn over.
|
||||
/**
|
||||
* The one thing worth calling out: the yard about to turn over — or the one that never will.
|
||||
*
|
||||
* THE COACH CASE IS ITS OWN WARNING (Jesse's ruling, 2026-09-17: say it loudly, change no rule).
|
||||
* Coaches travel one way. §9.2 boarding discards the emptied coach into the CLASSIFICATION Yard
|
||||
* and detraining draws a fresh empty out of the Division Yard, and §2.2 sends Classification back
|
||||
* only when the Division Yard runs bare. Measured over one three-Day game: sixteen coaches at
|
||||
* setup, none left by Day 2 Stage 8, fifteen piled in Classification, and the Division Yard
|
||||
* holding steady at 46-47 freight cars — so the refill will not fire before the game ends.
|
||||
*
|
||||
* Every consequence of that is silent on the board. A Depot simply offers no passenger action, and
|
||||
* the Sparrow — three coaches and nothing else — is made up empty and runs the whole Division that
|
||||
* way. The cars are in plain sight in Classification; what cannot be seen is that they are not
|
||||
* coming back. So the panel says it where the pile is.
|
||||
*/
|
||||
const held = (row: { type: string; loaded: number; empty: number }[], type: string): number => {
|
||||
const r = row.find((c) => c.type === type);
|
||||
return r ? r.loaded + r.empty : 0;
|
||||
};
|
||||
const bare = yards.divisionTotal === 0;
|
||||
const coachesStranded = !bare && held(yards.division, 'coach') === 0 && held(yards.classification, 'coach') > 0;
|
||||
$('divyard').classList.toggle('bare', bare);
|
||||
$('yardnote').textContent = bare
|
||||
? `The Division Yard is bare — the ${yards.classificationTotal} cars in Classification return to it now.`
|
||||
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
|
||||
: coachesStranded
|
||||
? `NO COACHES in the Division Yard — all ${held(yards.classification, 'coach')} are in Classification, ` +
|
||||
`which comes back only when the Division Yard is bare, and it still holds ${yards.divisionTotal} cars. ` +
|
||||
'Until then no passengers can board or detrain, and a train whose card calls for coaches is made up empty.'
|
||||
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
|
||||
}
|
||||
|
||||
function renderDistrict(f: Frame): void {
|
||||
@@ -2408,11 +2441,34 @@ function renderActions(
|
||||
* title of the ONE group `menu.makeUp` actually covers leaves every other "Making up …" group,
|
||||
* however it is titled, on screen where a player can act on it.
|
||||
*/
|
||||
/**
|
||||
* WHAT A ROLE IS FOR, said where the role is chosen (playtest, 2026-09-17).
|
||||
*
|
||||
* Tom had a train standing at his Depot with a loaded coach on it, picked the Freight Agent to
|
||||
* unload the passengers, and found nothing that would do it — "didn't have any opportunity to
|
||||
* unload passengers". Both halves of that were working correctly and neither was visible: the
|
||||
* Freight Agent stocks a box and never touches a train, and detraining is a PORTER's action in
|
||||
* the Cargo phase. Why it was impossible as well (§9.2 wants an empty coach from the Division
|
||||
* Yard) was already in the Blocked panel, off to the side — but a player who has chosen the
|
||||
* wrong role is not reading a panel about the right one.
|
||||
*
|
||||
* A SENTENCE, NOT A HOVER. The tip on the heading would have answered him only if he already
|
||||
* suspected he was in the wrong place, which is precisely what he did not.
|
||||
*/
|
||||
const ROLE_SCOPE: Record<string, string> = {
|
||||
'freightAgent.':
|
||||
'The Freight Agent stocks the green Loading box from the Division Yard, and frees a jam. ' +
|
||||
'Passengers board and detrain in the Cargo phase, with a Porter — not here.',
|
||||
'porter.': 'A Porter works passengers between a platform and a coach standing at your Office.',
|
||||
'laborer.': 'A Laborer moves one load one step along MEN → AT → WORK, and loads or unloads a spotted car.',
|
||||
};
|
||||
|
||||
html += menu.direct
|
||||
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title)
|
||||
.map(
|
||||
(g) =>
|
||||
`<div class="grp"><h3>${esc(g.title)}</h3>` +
|
||||
(ROLE_SCOPE[g.kind] ? `<p class="dim scope">${esc(ROLE_SCOPE[g.kind]!)}</p>` : '') +
|
||||
g.actions
|
||||
.map((a) => {
|
||||
// §6.2 — a drawn card has to be played or discarded before the turn can end. Keyed on
|
||||
|
||||
@@ -213,6 +213,9 @@ button.act.crew.on{border-color:var(--now);background:rgba(185,140,240,.18);colo
|
||||
/* actions */
|
||||
#actions{max-height:none}
|
||||
.grp{margin-bottom:6px}
|
||||
/* What a role is for, under its heading — a sentence a player reads once and stops re-asking. Sized
|
||||
below the buttons so it explains without competing with the thing being chosen. */
|
||||
.grp .scope{margin:2px 0 4px;font-size:11.5px;line-height:1.35}
|
||||
/* THESE ARE THE THINGS YOU CAN DO. An action carried the same grey border as every other panel
|
||||
on the page, so the one region that is clickable did not look it. Amber border and a lit face,
|
||||
used nowhere else, so "this is a move" is answered before the label is read. */
|
||||
|
||||
+119
-1
@@ -7,7 +7,7 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { advance, badlyMadeUp, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaOf, check, isBeingMadeUp } from '../src/engine/apply.ts';
|
||||
import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, MAX_CONSIST, STAGES_PER_DAY, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
@@ -1582,3 +1582,121 @@ describe('§8.1 counts only trains AHEAD of the one departing (Gitea#26)', () =>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* §7's make-up round is silent about what it could NOT do, and that silence was reported from a
|
||||
* table (2026-09-16): "train 5, the sparrow, has no coaches, which seems strange."
|
||||
*
|
||||
* The Sparrow's card calls for three coaches and nothing else. Every coach in that game had ended
|
||||
* up in the Classification Yard, which §2.2 returns only when the Division Yard runs bare — and the
|
||||
* Division Yard still held 46 freight cars, so it never would. `trainNeedingCars` therefore answered
|
||||
* "nothing to ask for" exactly as it answers "everything is full", the phase moved on, and the train
|
||||
* ran the length of the Division empty with no line anywhere saying why.
|
||||
*/
|
||||
describe('a train the Division Yard cannot supply says so', () => {
|
||||
/** Puts one train in the yard-filling state, with a yard holding only `types`. */
|
||||
const readyToFill = (yard: { type: string; loaded: boolean }[]) => {
|
||||
const s = game();
|
||||
// Stage 1's train is 1/2, the Crack Limited — coaches only, which is the shape that goes short.
|
||||
s.timetable = Array(STAGES_PER_DAY).fill(null);
|
||||
s.timetable[0] = 1;
|
||||
s.yards.divisionYard = yard as never;
|
||||
s.yards.classificationYard = [{ type: 'coach', loaded: false }] as never;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'newTrain';
|
||||
return s;
|
||||
};
|
||||
|
||||
it('reports the shortfall, with the cars, the waiting pile and why it will not come back', () => {
|
||||
// A yard of pure freight: nothing the Crack Limited will take.
|
||||
const s = readyToFill([
|
||||
{ type: 'boxcar', loaded: true },
|
||||
{ type: 'hopper', loaded: false },
|
||||
]);
|
||||
const { events } = advance(s);
|
||||
const short = events.find((e) => e.type === 'makeUpShort');
|
||||
assert.ok(short, 'a train that could be given nothing reported nothing');
|
||||
assert.equal(short.type === 'makeUpShort' && short.placed, 0);
|
||||
assert.ok(
|
||||
short.type === 'makeUpShort' && short.missing.includes('coach'),
|
||||
'the report did not name the category the yard could not supply',
|
||||
);
|
||||
assert.ok(
|
||||
short.type === 'makeUpShort' && short.waiting === 1,
|
||||
'the report did not count the cars waiting in the Classification Yard',
|
||||
);
|
||||
assert.ok(
|
||||
short.type === 'makeUpShort' && short.divisionYardHolds === 2,
|
||||
'the report did not say how far the Division Yard is from bare, which is what §2.2 turns on',
|
||||
);
|
||||
});
|
||||
|
||||
it('says nothing when the round can still be asked for a car', () => {
|
||||
// The same train, with coaches available: the phase must STOP for them rather than report.
|
||||
const s = readyToFill([
|
||||
{ type: 'coach', loaded: true },
|
||||
{ type: 'coach', loaded: true },
|
||||
]);
|
||||
const { events, needsInput } = advance(s);
|
||||
assert.equal(needsInput, true, 'the phase should be waiting for a car to be placed');
|
||||
assert.equal(
|
||||
events.some((e) => e.type === 'makeUpShort'),
|
||||
false,
|
||||
'a train that can still be filled was reported short',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* §8.2 and the Small Yard's nose sort, together — Jesse's ruling of 2026-09-17 was "any train may
|
||||
* sort to the nose, and it is held if it cannot depart".
|
||||
*
|
||||
* The holding half already existed and needed no new code, which is worth pinning precisely because
|
||||
* it is easy to assume otherwise: `badlyMadeUp` is deliberately DIRECTION-FREE, so a train with its
|
||||
* whole consist ahead of the engine is a PUSHING train and fit to run. What §8.2 refuses is a
|
||||
* broken-backed train — the engine buried among its own cars — and a caboose anywhere but the end
|
||||
* away from the engine.
|
||||
*/
|
||||
describe('a train sorted to the nose is judged by §8.2, not by where the engine is', () => {
|
||||
const tray = (consist: { type: string; loaded: boolean }[], engineAt: number) =>
|
||||
({ consist, engineAt }) as unknown as CrewTray;
|
||||
|
||||
it('lets a pushing train run — the whole consist ahead of the engine is made up', () => {
|
||||
const pushing = tray([{ type: 'caboose', loaded: true }, { type: 'boxcar', loaded: true }], 2);
|
||||
assert.equal(
|
||||
badlyMadeUp(pushing),
|
||||
null,
|
||||
'a pushing train was refused; §8.2 is enforced direction-free on purpose',
|
||||
);
|
||||
});
|
||||
|
||||
it('holds a train whose engine is buried among its own cars', () => {
|
||||
const buried = tray(
|
||||
[{ type: 'boxcar', loaded: true }, { type: 'hopper', loaded: true }, { type: 'caboose', loaded: true }],
|
||||
1,
|
||||
);
|
||||
const why = badlyMadeUp(buried);
|
||||
assert.ok(why !== null, 'a broken-backed train was allowed to leave the Office');
|
||||
assert.match(why, /engine is buried/, `the hold did not say why: ${why}`);
|
||||
});
|
||||
|
||||
it('holds a train whose caboose is not at the end away from the engine', () => {
|
||||
// Exactly the arrangement asked for at the table: caboose second from the rear.
|
||||
const wanted = tray(
|
||||
[
|
||||
{ type: 'hopper', loaded: true },
|
||||
{ type: 'tank', loaded: false },
|
||||
{ type: 'caboose', loaded: true },
|
||||
{ type: 'boxcar', loaded: true },
|
||||
],
|
||||
0,
|
||||
);
|
||||
const why = badlyMadeUp(wanted);
|
||||
assert.ok(why !== null, 'a train with its caboose mid-consist was allowed to leave');
|
||||
assert.match(why, /caboose must be at the rear/, `the hold did not say why: ${why}`);
|
||||
});
|
||||
});
|
||||
|
||||
+30
-9
@@ -1361,6 +1361,10 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
|
||||
const under = district(s);
|
||||
buildFacility(s, 'packingSheds', under);
|
||||
const cardId = modifierOf(s, 'iceHouse');
|
||||
// `district` leaves the host ON the sign's column, which since 2026-09-17 puts its three eastern
|
||||
// spots outside the Limits. The question here is whether DIAGONALS are offered at all, so the
|
||||
// sign goes out one more column and the host keeps all nine.
|
||||
areaOf(s, 0).limitsEast = at(areaOf(s, 0).runningRow, under.col + 1);
|
||||
|
||||
const offered = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined)
|
||||
@@ -1375,13 +1379,18 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
|
||||
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: southEast }), null);
|
||||
});
|
||||
|
||||
it('lets a Modifier hang outside the Limits, but never in the Running Track row', () => {
|
||||
it('keeps a Modifier inside the Limits, and out of the Running Track row', () => {
|
||||
/**
|
||||
* Jesse's call, both halves. A Modifier is not track (§9), so a host standing at the limit keeps
|
||||
* all nine of its spots — refusing the outer three would make the card unplayable exactly where
|
||||
* the district ends. The Running Track ROW is the exception: inside the Limits that row is
|
||||
* always full, so this bites only beyond the sign, and that is the ground the main grows onto —
|
||||
* a Modifier parked there would block the player's own sign from moving outward (§2.1).
|
||||
* Jesse's call, both halves — and the FIRST half reversed on 2026-09-17 after a Day 3 playtest
|
||||
* put Transmission Lines at (-2,4) with the sign at column 3. It used to read the other way: a
|
||||
* Modifier is not track (§9), so a host at the limit kept all nine of its spots, because
|
||||
* refusing the outer three looked like it would make the card unplayable where the district
|
||||
* ends. What decided it was the board — a card standing outside your own sign, in territory
|
||||
* §8.1 and §10 reason about — and a count of what is actually lost: six of the nine spots
|
||||
* survive, and the sign moves outward as the Running Track grows (§2.1, Gap 4a).
|
||||
*
|
||||
* The Running Track ROW stays barred for its own reason: it is the ground the main grows onto,
|
||||
* and a Modifier parked there would block the player's own sign from moving outward.
|
||||
*/
|
||||
const s = game();
|
||||
const under = district(s);
|
||||
@@ -1390,9 +1399,16 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
|
||||
const area = areaOf(s, 0);
|
||||
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
|
||||
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, area.limitsEast.col + 1) }),
|
||||
'OUTSIDE_LIMITS',
|
||||
'a Modifier was allowed to stand outside the district it belongs to',
|
||||
);
|
||||
// The spot inside the sign, beside the same host, is the one a player actually has — free,
|
||||
// adjacent, and on the sign's own column, which `withinLimits` includes.
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId, placement: at(under.row - 1, under.col) }),
|
||||
null,
|
||||
'a Modifier beside a host at the limit was refused the spot outside it',
|
||||
'a Modifier was refused a free, connected spot inside the Limits',
|
||||
);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'card.play', cardId, placement: at(area.runningRow, under.col + 1) }),
|
||||
@@ -1425,6 +1441,11 @@ describe("a Modifier grants only what its host's flow can use", () => {
|
||||
const area = areaOf(s, 0);
|
||||
const at = { row: -1, col: 4 };
|
||||
area.grid.set(coordKey(at), withFacility(kind, out, into) as never);
|
||||
// This test is about what a Modifier GRANTS, not about where it may stand, and it arranges a
|
||||
// host well east of the opening sign. Modifiers have been bounded by the Limits since
|
||||
// 2026-09-17, so the district has to reach the square the fixture uses or every case here would
|
||||
// fail as OUTSIDE_LIMITS and prove nothing about flow.
|
||||
area.limitsEast = { row: area.runningRow, col: at.col + 2 };
|
||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||
|
||||
const card = [...s.cards.entries()].find(
|
||||
@@ -1881,7 +1902,7 @@ describe('the engine is drawn pointing east or west, whatever track it is standi
|
||||
* So `facing` stays a PORT (movement needs one) and `railFacingOf` is what the board draws.
|
||||
*/
|
||||
const moved = (id: string, facing: 'n' | 's' | 'e' | 'w') =>
|
||||
({ type: 'trayMoved', player: 0, trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, facing }) as const;
|
||||
({ type: 'trayMoved', player: 0, trayId: id, from: at(0, 0), to: at(0, 0), movesRemaining: 3, movesAllowed: 6, facing }) as const;
|
||||
|
||||
it('carries the east-west sense across north-south track', () => {
|
||||
const s = game();
|
||||
|
||||
@@ -12,6 +12,9 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { describeIntent } from '../src/sim/view.ts';
|
||||
import { badlyMadeUp } from '../src/engine/advance.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { CrewTray, GameConfig, GameState, GridCoord, RollingStock, TrackArc, TrackCard } from '../src/engine/state.ts';
|
||||
import { carsOn, coordKey, turnOf } from '../src/engine/state.ts';
|
||||
@@ -461,3 +464,161 @@ describe('a 45° leg is part of the west-to-east row, not outside it (Gitea#17)'
|
||||
assert.deepEqual(standingAt(s, at(1, 0)), [], 'the cars should have come off the card');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The Small Yard's re-order menu, reported from Day 3 of the 2026-09-17 playtest.
|
||||
*
|
||||
* Jesse had train 10 on his Small Yard — nose first, `[loaded boxcar, loaded hopper, empty tank,
|
||||
* caboose]` — wanted the boxcar on the tail, and could not tell which button did it: every option
|
||||
* read `re-order consist [1,2,3,0]`, the engine's own array indices offered to a person. The move he
|
||||
* wanted was the FIRST of the five. One of the other four re-ordered nothing at all and would still
|
||||
* have spent one of his six Moves.
|
||||
*/
|
||||
describe('the Small Yard says what each re-order would build', () => {
|
||||
const smallYard = (standing: RollingStock[] = []): TrackCard => ({
|
||||
...straight(standing),
|
||||
enhancements: ['smallYard'],
|
||||
});
|
||||
|
||||
/** Train 10's consist as it actually stood, on a card carrying a Small Yard. */
|
||||
const onTheYard = (): { s: GameState; trayId: string } => {
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
addCard(s, at(1, 1), smallYard());
|
||||
const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true), car('tank'), car('caboose', true)], 'e');
|
||||
switching(s);
|
||||
return { s, trayId };
|
||||
};
|
||||
|
||||
it('never offers a sort that changes nothing', () => {
|
||||
/**
|
||||
* THE PAIR IS WHAT COUNTS, since the engine position became part of a sort (2026-09-17). The
|
||||
* identity car order is now a perfectly good option when it moves the ENGINE — that is the whole
|
||||
* of the separate engine control — so what must never be offered is the pair that reproduces the
|
||||
* train already standing there, and no pair may appear twice.
|
||||
*/
|
||||
const { s, trayId } = onTheYard();
|
||||
const tray = s.trays.get(trayId)!;
|
||||
const offered = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => {
|
||||
const sort = i as { order: number[]; engineAt?: number };
|
||||
return `${sort.order.join(',')}|${sort.engineAt ?? 0}`;
|
||||
});
|
||||
assert.ok(offered.length > 0, 'no sort was offered at all, so this proved nothing');
|
||||
const unchanged = `${[...tray.consist.keys()].join(',')}|${tray.engineAt}`;
|
||||
assert.ok(
|
||||
!offered.includes(unchanged),
|
||||
'the menu offered the train as it already stands — a Move spent to change nothing',
|
||||
);
|
||||
assert.equal(new Set(offered).size, offered.length, 'the menu offered the same sort twice');
|
||||
});
|
||||
|
||||
it('offers the engine every position, including ahead of its own cars', () => {
|
||||
// Jesse's ruling, 2026-09-17, following `implications.md` against the v0.4.5 card text: a train
|
||||
// in a Small Yard "may sort itself into any order, INCLUDING cars ahead of the engine".
|
||||
const { s, trayId } = onTheYard();
|
||||
const n = s.trays.get(trayId)!.consist.length;
|
||||
const positions = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => (i as { engineAt?: number }).engineAt ?? 0);
|
||||
for (let k = 1; k <= n; k++) {
|
||||
assert.ok(positions.includes(k), `the engine was never offered position ${k} of ${n}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('always offers a made-up order to a train that is not in one', () => {
|
||||
/**
|
||||
* Jesse, 2026-09-17: "trains with a caboose have to offer the caboose at the back… sorting all
|
||||
* the cars back in, ready to leave the station."
|
||||
*
|
||||
* A train with its caboose mid-consist is one §8.2 will not let out of the Office, so at least
|
||||
* one offer has to put it right. It comes out of the ordinary curated set — "bring car k to the
|
||||
* tail" is enumerated for every car, and the caboose is one of them.
|
||||
*/
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
addCard(s, at(1, 1), smallYard());
|
||||
const trayId = placeTray(s, at(1, 1), [car('boxcar', true), car('caboose', true), car('hopper', true)], 'e');
|
||||
const tray = s.trays.get(trayId)!;
|
||||
tray.trainNumber = 10;
|
||||
switching(s);
|
||||
|
||||
const fit = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.filter((i) => {
|
||||
const sort = i as { order: number[]; engineAt?: number };
|
||||
const after = sort.order.map((n) => tray.consist[n]!);
|
||||
return badlyMadeUp({ ...tray, consist: after, engineAt: sort.engineAt ?? 0 }) === null;
|
||||
});
|
||||
assert.ok(
|
||||
fit.length > 0,
|
||||
'a train that cannot leave the Office was offered no sort that would make it up',
|
||||
);
|
||||
// And the one that does it says so on the button, rather than leaving it to be discovered.
|
||||
assert.ok(
|
||||
fit.some((i) => describeIntent(s, i).includes('MADE UP, ready to leave')),
|
||||
'the sort that makes the train up does not say so',
|
||||
);
|
||||
});
|
||||
|
||||
it('lays the train out west to east, with the engine where it will be', () => {
|
||||
/**
|
||||
* "You specify above that the order is front to back, but on the screen, if it's eastbound or
|
||||
* westbound, it may look different" — Jesse, 2026-09-17.
|
||||
*
|
||||
* `board-svg.ts` draws the crew strip west on the left, reversing a consist for an east-facing
|
||||
* train so its nose lands at the east end. The button has to read the same way or it describes a
|
||||
* different train from the one on the board.
|
||||
*/
|
||||
const s = game();
|
||||
row(s, 3);
|
||||
addCard(s, at(1, 1), smallYard());
|
||||
// Engine points WEST: the consist is stored nose first, so west to east reads engine first.
|
||||
const west = placeTray(s, at(1, 1), [car('boxcar', true), car('hopper', true)], 'w');
|
||||
switching(s);
|
||||
const westLabel = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => describeIntent(s, i))
|
||||
.find((l) => l.startsWith('re-order'));
|
||||
assert.ok(westLabel, 'no re-order was offered for the west-facing train');
|
||||
assert.match(westLabel, /west to east: ◀ ENGINE · /, `a west-facing engine was not drawn leading: ${westLabel}`);
|
||||
|
||||
// The same train pointing EAST puts the engine at the far end of the same sentence.
|
||||
s.trays.get(west)!.railFacing = 'e';
|
||||
s.trays.get(west)!.facing = 'e';
|
||||
const eastLabel = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => describeIntent(s, i))
|
||||
.find((l) => l.startsWith('re-order'));
|
||||
assert.ok(eastLabel, 'no re-order was offered for the east-facing train');
|
||||
assert.match(eastLabel, / · ENGINE ▶($| ·)/, `an east-facing engine was not drawn at the east end: ${eastLabel}`);
|
||||
});
|
||||
|
||||
it('labels each option with the train it would make, not with array indices', () => {
|
||||
const { s } = onTheYard();
|
||||
const labels = legalActions(s, 0)
|
||||
.filter((i) => i.type === 'switch.sortConsist')
|
||||
.map((i) => describeIntent(s, i));
|
||||
|
||||
assert.ok(
|
||||
labels.every((l) => !/\[\d(,\d)*\]/.test(l)),
|
||||
`a re-order option still reads as a permutation: ${labels.find((l) => /\[\d(,\d)*\]/.test(l))}`,
|
||||
);
|
||||
/**
|
||||
* The one Jesse wanted, written the way the board draws it: this crew faces EAST, so the strip
|
||||
* runs west to east and the engine sits at the east end with the boxcar now furthest west.
|
||||
*/
|
||||
assert.ok(
|
||||
labels.includes('re-order — west to east: loaded boxcar · caboose · empty tank · loaded hopper · ENGINE ▶'),
|
||||
`the move that puts the boxcar on the tail was not offered in words — got: ${labels.join(' | ')}`,
|
||||
);
|
||||
// And the engine's own positions read as what they do, not as an index.
|
||||
assert.ok(
|
||||
labels.some((l) => l.startsWith('put the whole consist ahead of the engine —')),
|
||||
`the shoving sort was not offered in words — got: ${labels.join(' | ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,12 @@ const KNOWN_UNREDUCED = [
|
||||
'clearanceRequested',
|
||||
'dispatchBonusUsed',
|
||||
'expediteFault',
|
||||
/**
|
||||
* The New Train Phase's report that it could give a train nothing (playtest, 2026-09-16, the
|
||||
* Sparrow running empty). Emitted by the phase driver after the make-up round has nothing left to
|
||||
* offer, so it describes rather than reduces, like every entry on this list.
|
||||
*/
|
||||
'makeUpShort',
|
||||
'phaseBegan',
|
||||
/**
|
||||
* §Q, Red Flags (Gitea#19). The flag comes down inside the phase driver as it stops a train, so
|
||||
@@ -67,6 +73,12 @@ const KNOWN_UNREDUCED = [
|
||||
* riding on `actorChanged` meant the log dropped it as turn bookkeeping.
|
||||
*/
|
||||
'superintendentChanged',
|
||||
/**
|
||||
* The line that closes a switching turn. Emitted by `switch.end` beside the `phaseEnded` that
|
||||
* actually ends the turn, and reduces to nothing itself: it reports what the Moves were spent on
|
||||
* and where the crew was left, both of which the state already holds.
|
||||
*/
|
||||
'switchingEnded',
|
||||
'trainArrived',
|
||||
'trainCompleted',
|
||||
'trainDiverted',
|
||||
|
||||
+14
-3
@@ -48,7 +48,8 @@ const SAMPLES: GameEvent[] = [
|
||||
// the table every third Stage, so its text is worth exercising.
|
||||
{ type: 'superintendentChanged', player: 1, stage: 6 },
|
||||
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
||||
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
|
||||
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5, movesAllowed: 6 },
|
||||
{ type: 'switchingEnded', player: 0, movesUsed: 3, movesAllowed: 6, lastMove: { trayId: 't0', to: { row: 0, col: 1 } } },
|
||||
{ type: 'carsCoupled', player: 0, trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
||||
{ type: 'carsDropped', player: 0, trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
|
||||
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
|
||||
@@ -61,8 +62,18 @@ const SAMPLES: GameEvent[] = [
|
||||
{ type: 'inboundCleared', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
|
||||
{ type: 'facilityUnjammed', player: 0, at: { row: 1, col: 0 }, from: 'menAtWork', stock: { type: 'hopper', loaded: true } },
|
||||
{ type: 'trainScheduled', player: 0, trainNumber: 4, roll: 7, slot: 6, rngState: 1 },
|
||||
{ type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false } },
|
||||
{ type: 'carPassed', player: 0, trayId: 't0' },
|
||||
{ type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false }, trainNumber: 10, isExtra: false },
|
||||
{ type: 'carPassed', player: 0, trayId: 't0', trainNumber: 10, isExtra: false },
|
||||
{
|
||||
type: 'makeUpShort',
|
||||
trainNumber: 5,
|
||||
isExtra: false,
|
||||
placed: 0,
|
||||
wanted: 3,
|
||||
missing: ['coach'],
|
||||
waiting: 14,
|
||||
divisionYardHolds: 46,
|
||||
},
|
||||
{ type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
|
||||
{ type: 'clearanceGiven', trainId: 't1', allow: false },
|
||||
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 0 },
|
||||
|
||||
+166
-3
@@ -12,9 +12,10 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { applyIntent } from '../src/engine/apply.ts';
|
||||
import { applyIntent, areaOf } from '../src/engine/apply.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import type { GameConfig, PlayerIndex } from '../src/engine/state.ts';
|
||||
import { turnOf } from '../src/engine/state.ts';
|
||||
import { fromMultiplayerSave, newGame, newMultiplayerGame, submit } from '../src/web/game.ts';
|
||||
import { currentActor } from '../src/web/game.ts';
|
||||
import { applyPublicDelta } from '../src/sim/public-delta.ts';
|
||||
@@ -55,7 +56,18 @@ const config: GameConfig = {
|
||||
const SWITCHING_EVENTS = ['trayMoved', 'carsCoupled', 'carsDropped', 'consistSorted'] as const;
|
||||
|
||||
/** How each of those four reads in the log, so the assertions can find them by text. */
|
||||
const SWITCHING_LINE = /^Player .+ (moved (Train |the local crew)|coupled \d+ car|set out |used the SMALL YARD)/;
|
||||
const SWITCHING_LINE = /^Player .+ (moved (Train |the local crew)|coupled at |set out |used the SMALL YARD)/;
|
||||
|
||||
/**
|
||||
* The two tones a switching line may carry, and why attribution matters in BOTH.
|
||||
*
|
||||
* Since 2026-09-17 a plain move along your own track is written `trace`: the line still exists, so
|
||||
* its display step has narration to caption the board with and a dwell to be watched for, but the
|
||||
* history panel does not draw it (`web/game.ts` § inHistory). What must never happen either way is
|
||||
* the line failing to say whose crew it was — the caption is read by the whole table while the move
|
||||
* goes up, which is if anything the more public of the two places.
|
||||
*/
|
||||
const SWITCHING_TONES = ['act', 'trace'];
|
||||
|
||||
describe('switching is attributed — TODO #13', () => {
|
||||
it('every switching event carries the player who acted', () => {
|
||||
@@ -124,7 +136,10 @@ describe('switching is attributed — TODO #13', () => {
|
||||
`an unattributed switching line survived: ${line.text}`,
|
||||
);
|
||||
if (SWITCHING_LINE.test(line.text)) {
|
||||
assert.equal(line.tone, 'act', `a switching line must read as somebody's move: ${line.text}`);
|
||||
assert.ok(
|
||||
SWITCHING_TONES.includes(line.tone),
|
||||
`a switching line must read as somebody's move: ${line.text} (tone ${line.tone})`,
|
||||
);
|
||||
lines++;
|
||||
}
|
||||
}
|
||||
@@ -404,3 +419,151 @@ describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', (
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* HOW MUCH SWITCHING REACHES THE HISTORY PANEL — Jesse's ruling, 2026-09-17, asked as a question
|
||||
* from the table: *"Does switching show up in history at all? Should it? … I don't think I want all
|
||||
* six moves showing up… maybe dropping off or picking up cars in industries should be recorded."*
|
||||
*
|
||||
* It all showed up. A six-Move turn wrote a line per move, every one of them a pair of coordinates,
|
||||
* and two players shunting pushed everything else off the panel. What stays is the line saying
|
||||
* somebody switched, work at an INDUSTRY, and the Small Yard sort.
|
||||
*
|
||||
* THE LINE IS STILL WRITTEN, MARKED `trace`. `dwellForStep` gives a step no dwell when it produced
|
||||
* no narration, so dropping these outright stopped the board replaying switching at all — the first
|
||||
* attempt at this did exactly that and the step-queue suite caught it. The tone is the seam: the
|
||||
* caption still has its text, the panel filters the tone out.
|
||||
*/
|
||||
describe('the history panel keeps the switching that matters', () => {
|
||||
const play = (seed: number, steps: number) => {
|
||||
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const options = legalActions(game.state, actor);
|
||||
if (options.length === 0) break;
|
||||
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
||||
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
||||
}
|
||||
return game;
|
||||
};
|
||||
|
||||
it('keeps the first move of a turn and traces the ones after it', () => {
|
||||
const game = play(1917398, 800);
|
||||
const moves = game.log.filter((l) => / moved (Train|the local crew)/.test(l.text));
|
||||
assert.ok(moves.length > 0, 'no crew move reached the log, so this proved nothing');
|
||||
|
||||
// The opener of a turn is the move that leaves `movesAllowed - 1` behind it, which the line
|
||||
// prints — "5 of 6 Moves left". Those are drawn; everything after them is caption-only.
|
||||
const opening = moves.filter((l) => / 5 of 6 Moves left/.test(l.text));
|
||||
const later = moves.filter((l) => !/ 5 of 6 Moves left/.test(l.text));
|
||||
assert.ok(opening.length > 0, 'no turn opened with a move, so this proved nothing');
|
||||
assert.ok(later.length > 0, 'no turn made a second move, so this proved nothing');
|
||||
assert.ok(
|
||||
opening.every((l) => l.tone !== 'trace'),
|
||||
`the first move of a switching turn was hidden: ${opening.find((l) => l.tone === 'trace')?.text}`,
|
||||
);
|
||||
assert.ok(
|
||||
later.every((l) => l.tone === 'trace'),
|
||||
`a move from the middle of a turn is still drawn: ${later.find((l) => l.tone !== 'trace')?.text}`,
|
||||
);
|
||||
// Every one keeps its text, because that is what captions the board as the move goes up.
|
||||
assert.ok(moves.every((l) => /^Player /.test(l.text)), 'a trace line lost its attribution');
|
||||
});
|
||||
|
||||
it('closes a switching turn with what it cost and where the crew was left', () => {
|
||||
/**
|
||||
* BUILT, NOT PLAYED — the driver above never submits `switch.end`: it finds a move or another
|
||||
* option every time, so 800 turns produced no closed switching turn at all and the assertion
|
||||
* would have been vacuous.
|
||||
*/
|
||||
const { game, trayId } = crewOnAnIndustry();
|
||||
assert.ok(submit(game, { type: 'switch.move', trayId, to: { row: 1, col: 2 }, reverse: false }));
|
||||
assert.ok(submit(game, { type: 'switch.end' }), 'the turn would not end');
|
||||
|
||||
const closing = game.log.filter((l) => / finished switching/.test(l.text));
|
||||
assert.equal(closing.length, 1, `expected one closing line, got ${closing.length}`);
|
||||
assert.notEqual(closing[0]!.tone, 'trace', 'the closing summary was hidden from the history');
|
||||
assert.match(
|
||||
closing[0]!.text,
|
||||
/1 of 6 Moves used, leaving .* at /,
|
||||
`the closing line did not say what it cost and where the crew was left: ${closing[0]!.text}`,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A crew standing on a Freight House with a loaded boxcar, and plain track to its east.
|
||||
*
|
||||
* BUILT RATHER THAN PLAYED. The bot prefers moves over couplings, so 2400 driven turns across
|
||||
* three seeds produced not one set-out at an industry, and none of them ever ended a switching
|
||||
* turn — a driver that cannot reach the case cannot test it.
|
||||
*/
|
||||
const crewOnAnIndustry = (): { game: ReturnType<typeof newGame>; trayId: string } => {
|
||||
const game = newGame(77);
|
||||
const area = areaOf(game.state, 0);
|
||||
const spot = { row: 1, col: 1 };
|
||||
const track = (over: object = {}) => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
...over,
|
||||
});
|
||||
area.grid.set('1,1', track({
|
||||
geometry: { kind: 'facility', facility: 'freightHouse', axis: 'ew' },
|
||||
facility: {
|
||||
kind: 'freight', subtype: 'freightHouse',
|
||||
allows: { outbound: true, inbound: true },
|
||||
outboundBox: [], inboundBox: [],
|
||||
capacity: { outbound: 1, inbound: 1 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
}) as never);
|
||||
area.grid.set('1,2', track() as never);
|
||||
|
||||
const trayId = game.state.freeTrays.pop()!;
|
||||
game.state.trays.set(trayId, {
|
||||
id: trayId, trainNumber: null, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'boxcar', loaded: true }],
|
||||
direction: 'east', facing: 'e', railFacing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: spot }, movesUsed: 0,
|
||||
} as never);
|
||||
game.state.clock.phase = 'localOps';
|
||||
game.state.clock.currentActor = 0;
|
||||
turnOf(game.state, 0).option = 'switch';
|
||||
return { game, trayId };
|
||||
};
|
||||
|
||||
it('keeps work at an industry, and drops the same move on plain track', () => {
|
||||
const { game, trayId } = crewOnAnIndustry();
|
||||
const plain = { row: 1, col: 2 };
|
||||
assert.ok(submit(game, { type: 'switch.dropCars', trayId, count: 1 }), 'the set-out was refused');
|
||||
const dropped = game.log.filter((l) => /set out/i.test(l.text));
|
||||
assert.equal(dropped.length, 1, `expected one set-out line, got ${dropped.length}`);
|
||||
assert.notEqual(
|
||||
dropped[0]!.tone,
|
||||
'trace',
|
||||
'work at an industry was hidden from the history — it is the point of switching',
|
||||
);
|
||||
assert.match(
|
||||
dropped[0]!.text,
|
||||
/at the Freight House/,
|
||||
`the line named a coordinate instead of the industry: ${dropped[0]!.text}`,
|
||||
);
|
||||
|
||||
// The same crew moving onto ordinary track is the noise this ruling was about.
|
||||
assert.ok(submit(game, { type: 'switch.move', trayId, to: plain, reverse: false }), 'the move was refused');
|
||||
const moved = game.log.filter((l) => / moved the local crew/.test(l.text));
|
||||
assert.equal(moved.length, 1, `expected one move line, got ${moved.length}`);
|
||||
// The FIRST move of a turn is kept, and this crew's first move is this one — so what is being
|
||||
// checked here is that it names the square by what stands on it rather than by its coordinates.
|
||||
assert.match(moved[0]!.text, /→ the Freight House|→ \(1,2\)/, `unexpected move line: ${moved[0]!.text}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5480,3 +5480,32 @@ describe('the Superintendent ruling names the train it is ruling on', () => {
|
||||
assert.match(title, /Train 4/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* §2.2 and §9.2 together strand every coach in the Classification Yard, and the board does not say
|
||||
* so — Jesse's ruling of 2026-09-17 is that the rule stands and the game says it loudly.
|
||||
*
|
||||
* Tested on the FRAME rather than the DOM, because the counts are what the warning is derived from
|
||||
* and they are what could go wrong: the panel asks for a per-type count the Frame already carries.
|
||||
*/
|
||||
describe('a Division Yard with no coaches is a reportable condition', () => {
|
||||
it('carries per-type counts for both yards, so the panel can tell coaches from cars', () => {
|
||||
const game = newGame(4242);
|
||||
game.state.yards.divisionYard = [{ type: 'boxcar', loaded: true }, { type: 'hopper', loaded: false }] as never;
|
||||
game.state.yards.classificationYard = [
|
||||
{ type: 'coach', loaded: false },
|
||||
{ type: 'coach', loaded: true },
|
||||
] as never;
|
||||
const f = view(game);
|
||||
|
||||
const coachesInDivision = f.yards.division.find((c) => c.type === 'coach');
|
||||
assert.equal(coachesInDivision, undefined, 'the fixture put no coach in the Division Yard');
|
||||
assert.notEqual(f.yards.divisionTotal, 0, 'a yard holding freight is not bare, which is the whole point');
|
||||
|
||||
const waiting = f.yards.classification.find((c) => c.type === 'coach');
|
||||
assert.ok(waiting, 'the coaches in Classification were not reported by type');
|
||||
assert.equal(waiting.loaded + waiting.empty, 2, 'the waiting coaches were miscounted');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user