v0.4.2 fixed passenger trains not stopping at office. changeed revenue for train completing run - 1 point to every player when train exits division

This commit is contained in:
Jesse
2026-08-13 21:29:52 -04:00
parent a7221dcf20
commit 393a669418
21 changed files with 1930 additions and 1531 deletions
+65
View File
@@ -21,6 +21,71 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
## Unreleased
## 0.4.2 — 2026-08-13
### A completed run pays the whole table
The departure Revenue used to pay 1 to the Office a train left — "it cleared YOUR section". On a
five-Office railroad that paid five separate times for one train, and paid most to whichever Office it
happened to pass first. It now pays **once, when the train runs off the end of the Division, and it
pays every player**: getting a train the length of the railroad is the shared achievement, and every
Office it crossed had to clear it to happen.
The log says so in one line — *"Train 5 has completed its run, leaving via the Eastern Division Point
carrying 3 coaches. All players get 1 Revenue."* — and because nobody took a turn to cause it, it is
also announced on screen and given a sound of its own: two long horn notes falling away, the second
lower and quieter. It is the only cue in the game that is not somebody's action.
Solitaire is nearly unmoved, 7.0 → 7.3 mean over 200 games, because one player's departures and
completions run at almost the same rate. **A multi-player game is a completely different shape** and
needs measuring once there is one.
### Expedited passenger trains could never be worked, and now can
**Reported from play: "passenger trains arrive at my Office and move on before I can load or unload."**
They did. Every coach-carrying express prints Expedite — 1/2 Crack Limited with three coaches, 5/6 The
Sparrow with two, 19 Military with two — and Expedite was implemented as a second move inside the same
Mainline Phase. Load/Unload runs *after* Mainline, so the train was always gone first. Measured over 60
games: **Train 2 arrived 32 times and stood for a Load/Unload phase in none of them.** Trains 4 and 6
likewise zero. The Crack Limited's own card says "stop at Terminals only", which it could never do.
An expedited train now stands through Load/Unload and departs at the **end** of the Stage instead. Q3's
"departs the Stage it arrives" is still literally true — it does not lay over — and §8.1 still applies,
so it can be held. Coach trains standing for Load/Unload went **116 → 229** across the same 60 games;
Train 2 from 0 to 35, Train 6 from 0 to 17.
The cost is real and is logged as drift: a train occupying an A/D track and the Office square for a
Stage is in the crew's way, so switching work fell ~16% (1.76 → 1.48 productive acts over 400 games)
and the worst game went −3 → −9 as Offices fill. That is the change doing what it is supposed to do.
### Three silences around passenger work, all of which said nothing at all
The refusal codes never reach a player — the menu simply does not offer an illegal action — so the
Blocked panel is the only thing that can explain a missing button, and it skipped every non-freight
facility. Its passenger section also only looked at trains carrying a *loaded* coach, so a train
arriving to **pick up** produced no line at all.
Underneath, the engine's own answers were wrong in two ways worth fixing regardless:
- **A Whistle Post reported `RESOURCE_SPENT`** — "all Porters already used this Stage" — to a player
who had used none. Its Office card carries a passenger facility so that an upgrade is a property
change rather than a card swap, and that facility has `porters: 0`. Now `NO_PORTERS_HERE`, and the
panel says to upgrade the Office. 15 occurrences in 60 games.
- **`NO_TRAIN_AT_OFFICE` was returned while a train was standing at the Office**, as the catch-all for
every other reason. 51 occurrences with a coach train in front of the player: 27 with nobody waiting
to travel, 24 with passengers waiting and every coach already full. Those are now
`NO_PASSENGERS_WAITING`, `NO_EMPTY_COACH`, `NO_LOADED_COACH`, `INBOUND_BOX_FULL` and
`NO_EMPTY_COACH_IN_YARD`, and each has its own line in the panel.
The panel also now warns, in amber, when the train in front of you is expedited: *"it leaves at the END
of this Stage, so this is the only Load/Unload phase it will stand for."*
### Replays
`seed-6257010` died on the Expedite change — it replayed 161 of 335 intents, which on screen looks
exactly like a game that ended early. Re-recorded with `save-replay.ts`; `seed-3334899` survived
untouched and was kept. Four published replays, all verified to replay to their last intent.
## 0.4.1 — 2026-08-13
### §4.4's opening D12 now decides who sits where
+1 -1
View File
@@ -10,7 +10,7 @@ train into an occupied Subdivision. Get that wrong and two trains meet at speed.
## Status
**v0.4.1 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure,
**v0.4.2 — solitaire is playable in a browser.** The whole game runs client-side: the engine is pure,
imports nothing outside itself, and never touches `Math.random`, so a static host is all it needs.
- **Rules** — specified, with **three open questions** left. Thirteen gaps in the original prototype
+20 -7
View File
@@ -27,13 +27,26 @@ Ordered within each section by how much it is currently costing us.
watch at the table: whether opening with six against a limit of three is a real decision or
just bookkeeping, and whether three is the right number of each.
**One Revenue for every train that clears your section.** Worth exactly **+5.40 a game** —
departures run at 5.40 and each pays 1 — taking the bot from 2.7 to 8.0 mean, median 0 → 6.5,
wins 2/200 → 14/200. It more than doubles the score on its own and pays for traffic the player
does not have to work. **This makes the victory-target question live**: 20 over 5 Days is now
reachable largely on traffic, which is either the intent or an argument for raising the target.
- [ ] **BOT DRIFT ACROSS THIS RELEASE — three measurements, all for the rebalance pass.** Recorded
together so the pattern is visible rather than three relaxed thresholds nobody adds up:
**~~One Revenue for every train that clears your section.~~ Now: one Revenue to EVERY player
when a train completes its run.** Jesse's revision in v0.4.2. The first version paid the Office
a train departed, which on a five-Office railroad paid five separate times for one train and
paid most to whoever it passed first. It pays once now, when the train runs off the end of the
Division, and it pays the whole table — getting a train the length of the railroad is the
shared achievement, and every Office it crossed had to clear it.
Solitaire is nearly unmoved (7.0 → 7.3 mean over 200 games) because one player's departures and
completions run at almost the same rate; **in a multi-player game the shape is completely
different** and needs measuring once multiplayer exists — N players × 1 per completed run
against the old N payments per train. **The victory-target question stays live**: 20 over 5 Days
is still reachable largely on traffic, which is either the intent or an argument for raising it.
- [ ] **BOT DRIFT ACROSS THIS RELEASE — four measurements, all for the rebalance pass.** Recorded
together so the pattern is visible rather than four relaxed thresholds nobody adds up:
- **Switching work down ~16%, 1.76 → 1.48 productive acts a game** (400 games), because an
expedited train now stands at the Office for a Stage instead of passing straight through, and
a train on the A/D track and the Office square is in the crew's way. That is the change doing
its job rather than a fault — but it is drift. Collisions also went 0.05 → 0.06 and the worst
game went −3 → −9, same cause: the Office fills up. **Separately, the `work > 2` floor that
caught this had never actually been met** — it read 2.16 at 150 games and 1.76 at 400, so it
was passing on which seeds the sample happened to include. Now 400 games and a floor of 1.2.
- **Track laid badly, 7% → 15%** of pieces butting a card that cannot accept them. Forced to
shed on turn one, the bot would rather lay a piece than discard it; a player would discard
the ones with nowhere good to go. It also means the district-size gain from the new deal is
+1 -1
View File
@@ -49,7 +49,7 @@ must do.
## Current status
**v0.4.1.** Rules formalized, card faces specified, architecture documented, and the game playable
**v0.4.2.** Rules formalized, card faces specified, architecture documented, and the game playable
solitaire in a browser. See [`../CHANGELOG.md`](../CHANGELOG.md) for what each version changed and
[`../TODO.md`](../TODO.md) for what is open; this section is the shape of the project, not a
running tally, because a hand-maintained tally is what drifted last time.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.4.1",
"version": "0.4.2",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
File diff suppressed because it is too large Load Diff
+16 -32
View File
@@ -1,7 +1,7 @@
{
"seed": 2242077,
"title": "31 Revenue · seed 2242077",
"note": "31 Revenue over 5 Days · 3 train(s) on the timetable · 19 cards down · 6 collision(s) · played by developer",
"title": "30 Revenue · seed 2242077",
"note": "30 Revenue over 5 Days · 3 train(s) on the timetable · 19 cards down · 3 collision(s) · played by developer",
"history": [
{
"type": "localOps.choose",
@@ -514,10 +514,6 @@
},
"carType": "boxcar"
},
{
"type": "mainline.clearance",
"allow": false
},
{
"type": "laborer.advanceLoad",
"at": {
@@ -529,6 +525,10 @@
{
"type": "loadUnload.end"
},
{
"type": "mainline.clearance",
"allow": false
},
{
"type": "localOps.choose",
"option": "draw"
@@ -544,10 +544,6 @@
{
"type": "draw.end"
},
{
"type": "mainline.clearance",
"allow": false
},
{
"type": "laborer.advanceLoad",
"at": {
@@ -921,10 +917,6 @@
},
"index": 0
},
{
"type": "mainline.clearance",
"allow": false
},
{
"type": "laborer.advanceLoad",
"at": {
@@ -936,6 +928,10 @@
{
"type": "loadUnload.end"
},
{
"type": "mainline.clearance",
"allow": false
},
{
"type": "localOps.choose",
"option": "switch"
@@ -943,10 +939,6 @@
{
"type": "switch.end"
},
{
"type": "mainline.clearance",
"allow": false
},
{
"type": "laborer.advanceLoad",
"at": {
@@ -1269,11 +1261,11 @@
"type": "draw.end"
},
{
"type": "mainline.clearance",
"allow": false
"type": "loadUnload.end"
},
{
"type": "loadUnload.end"
"type": "mainline.clearance",
"allow": false
},
{
"type": "localOps.choose",
@@ -1282,10 +1274,6 @@
{
"type": "switch.end"
},
{
"type": "mainline.clearance",
"allow": false
},
{
"type": "loadUnload.end"
},
@@ -1572,11 +1560,11 @@
"type": "draw.end"
},
{
"type": "mainline.clearance",
"allow": false
"type": "loadUnload.end"
},
{
"type": "loadUnload.end"
"type": "mainline.clearance",
"allow": false
},
{
"type": "localOps.choose",
@@ -1593,10 +1581,6 @@
{
"type": "draw.end"
},
{
"type": "mainline.clearance",
"allow": false
},
{
"type": "loadUnload.end"
},
+75 -46
View File
@@ -339,7 +339,7 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
(tray.position.at !== 'grid' ||
where.at !== 'grid' ||
(tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col));
if (moved !== 'expedited' && stillThere) {
if (!tray.departsThisStage && stillThere) {
tray.stopPointClaimed = true;
// The point goes to whoever is SITTING in the district it stopped in.
const owner = tray.position.at === 'grid' ? playerAtSeat(s, tray.position.seat) : 0;
@@ -364,11 +364,6 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
}
}
if (moved === 'needsClearance') return { events, needsInput: true };
// An expedited train may act twice in one Stage: it arrives and departs (Q3).
if (moved === 'expedited') {
const again = moveTrain(s, id, tray, events);
if (again === 'needsClearance') return { events, needsInput: true };
}
s.movedThisPhase.add(id);
}
@@ -376,7 +371,7 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
return { events: [...events, ...enterPhase(s, 'loadUnload')], needsInput: false };
}
type MoveOutcome = 'moved' | 'held' | 'needsClearance' | 'expedited';
type MoveOutcome = 'moved' | 'held' | 'needsClearance';
/**
* §8.2 — why this train is not fit to run, or null if it is.
@@ -565,7 +560,6 @@ function moveTrain(
from: 'the Office',
to: 'the Mainline',
});
awardDeparture(s, seat, tray, events);
return 'moved';
}
@@ -580,8 +574,7 @@ function moveTrain(
*/
if (node.kind === 'divisionPoint') {
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
awardDeparture(s, seat, tray, events);
retireTrain(s, id, tray, events);
retireTrain(s, id, tray, node.side, events);
return 'moved';
}
@@ -663,7 +656,7 @@ function moveTrain(
// The train has run the length of the Division and leaves the game (§2.3).
dest.holding.push(id);
tray.position = { at: 'divisionPoint', side: dest.side };
retireTrain(s, id, tray, events);
retireTrain(s, id, tray, dest.side, events);
return 'moved';
}
@@ -844,9 +837,24 @@ function arriveAtOffice(
expedited: isExpedited(tray),
});
// Q3 — an expedited train departs in the same Stage it arrived, so it is NOT added to
// movedThisPhase and gets a second chance to move before the phase ends.
if (isExpedited(tray)) return 'expedited';
/**
* Q3 — AN EXPEDITED TRAIN DEPARTS THE STAGE IT ARRIVED, BUT NOT BEFORE THE WORK IS DONE.
*
* It used to leave inside this very Mainline Phase — a second `moveTrain` on the spot. That made
* "departs the Stage it arrives" true and the coaches worthless: Load/Unload runs AFTER Mainline,
* so an expedited train was gone before a single Porter could reach it. Measured over 60 games,
* Train 2 arrived 32 times and stood at an Office during Load/Unload exactly none of them, and
* every coach-carrying express is expedited (1/2 Crack Limited, 5/6 The Sparrow, 19 Military).
* The Crack Limited's own card says "stop at Terminals only", which it could never do.
*
* So it stands here through Load/Unload and leaves at the end of the Stage instead. It still does
* not lay over to the next Stage — the keyword is honoured — and it is still subject to every
* §8.1 condition when it goes, so it can be held.
*/
if (isExpedited(tray)) {
tray.departsThisStage = true;
return 'moved';
}
/**
* X17 Campaign Train — the speeches happen at the first Office it reaches. Recorded AFTER the
* expedite test above, so this arrival is the ordinary stop the card pays for and every later one
@@ -907,47 +915,47 @@ function collide(
}
}
/** A completed run frees the crew; Extras go to the Salvage Yard (§2.3, Gap 2c). */
/**
* ONE REVENUE FOR EVERY TRAIN THAT GETS OUT OF YOUR SECTION.
* ONE REVENUE TO EVERY PLAYER WHEN A TRAIN COMPLETES ITS RUN.
*
* Jesse's rule. Unlike freight and passengers it pays for traffic the player does not have to work —
* which is the point: keeping the line clear is the Superintendent's job, and until now nothing paid
* for doing it well. It rewards splitting a Subdivision with a Control Point, holding a following
* train rather than gambling on it, and building the A/D capacity to turn arrivals around.
* Jesse's rule, revised. It used to pay 1 to the Office a train departed — "it left YOUR section" —
* which meant a five-Office railroad paid five times for one train and paid the most to whoever the
* train happened to pass first. It pays once now, when the train runs off the end of the Division,
* and it pays EVERYBODY: getting a train the whole length of the railroad is the shared achievement,
* and every Office it crossed had to clear it.
*
* Paid on DEPARTURE from the Office, once per train. Not paid again when the train later runs off
* the end of the Division and retires: it left YOUR section when it cleared your Office, and the far
* Division Point belongs to whoever is seated there.
* Unlike freight and passengers it pays for traffic no one has to work, which is the point: keeping
* the line moving is the Superintendent's job, and nothing else paid for doing it well. It rewards
* splitting a Subdivision with a Control Point, holding a following train rather than gambling on
* it, and building the A/D capacity to turn arrivals around.
*
* A crew with no train number is a local switching move and not a departure, so it earns nothing.
* A crew with no train number is a local switching move, not a run, so it earns nothing.
*
* PROVISIONAL, and worth watching: measured, this is roughly +5 Revenue a game against a mean of
* 2.7, so it more than doubles the score on its own. Flagged in `TODO.md`.
* PROVISIONAL — the previous version was worth about +5 Revenue a game against a mean of 2.7, and
* this one pays far less often. Flagged in `TODO.md`.
*/
function awardDeparture(
s: GameState,
seat: SeatIndex,
tray: CrewTray,
events: GameEvent[],
): void {
function awardCompletedRun(s: GameState, tray: CrewTray, events: GameEvent[]): void {
if (tray.trainNumber === null) return;
// Paid to whoever OCCUPIES the Office the train left, not to the seat's index. Identical today,
// and the difference is the whole point of the seat/player split.
const owner = playerAtSeat(s, seat);
const p = s.players[owner];
if (!p) return;
p.revenue += 1;
events.push({
type: 'revenueChanged',
player: owner,
delta: 1,
total: p.revenue,
reason: 'a train cleared your section',
});
for (const p of s.players) {
p.revenue += 1;
events.push({
type: 'revenueChanged',
player: p.index,
delta: 1,
total: p.revenue,
reason: 'a train completed its run',
});
}
}
function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]): void {
/** A completed run frees the crew; Extras go to the Salvage Yard (§2.3, Gap 2c). */
function retireTrain(
s: GameState,
id: TrayId,
tray: CrewTray,
side: Direction,
events: GameEvent[],
): void {
for (const car of tray.consist) {
if (car.type === 'caboose') s.yards.divisionYard.push(car);
else s.yards.classificationYard.push(car);
@@ -961,8 +969,11 @@ function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent
events.push({
type: 'trainCompleted',
trainNumber: tray.trainNumber ?? 0,
isExtra: tray.trainIsExtra,
side,
consist: tray.consist.map((c) => ({ ...c })),
});
awardCompletedRun(s, tray, events);
}
// ---------------------------------------------------------------------------
@@ -970,6 +981,24 @@ function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent
// ---------------------------------------------------------------------------
function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
/**
* EXPEDITED DEPARTURES — the last thing that happens in a Stage (Q3, and `arriveAtOffice`).
*
* Run here rather than as a phase of its own because there is no decision in it: the trains that
* arrived expedited this Stage try to leave, in train-number order like any other movement, and
* §8.1 may still hold them. Being after Load/Unload is the whole point — it is what gives the
* Porters their one Stage with the train.
*/
const expedited = [...s.trays.entries()]
.filter(([, t]) => t.departsThisStage)
.sort(([, a], [, b]) => (a.trainNumber ?? 0) - (b.trainNumber ?? 0));
for (const [id, tray] of expedited) {
tray.departsThisStage = false;
// A clearance question cannot be asked here — the Superintendent's phase is over — so an
// expedited train that would need a ruling simply stays, and runs normally next Stage.
if (moveTrain(s, id, tray, events) === 'needsClearance') continue;
}
// §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12.
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);
+47 -10
View File
@@ -457,12 +457,35 @@ function refusesThisOffice(s: GameState, player: PlayerIndex, tray: CrewTray): b
* this works out whether a card is the reason. Without it a Military train standing at the platform
* reported "no train at the Office", which is both wrong and unhelpful.
*/
function passengerRefusal(s: GameState, player: PlayerIndex): RejectionCode {
function passengerRefusal(
s: GameState,
player: PlayerIndex,
at: GridCoord,
dir: 'board' | 'detrain',
): RejectionCode {
const area = areaOf(s, player);
const trains = area.adOccupancy.map((id) => s.trays.get(id)).filter((t): t is CrewTray => !!t);
if (trains.length > 0 && trains.every((t) => refusesThisOffice(s, player, t))) return 'NOT_A_TERMINAL';
if (trains.length > 0 && trains.every(refusesPassengers)) return 'NO_PASSENGER_WORK';
return 'NO_TRAIN_AT_OFFICE';
if (trains.length === 0) return 'NO_TRAIN_AT_OFFICE';
/**
* A TRAIN IS STANDING THERE, so say what is actually missing.
*
* This used to fall through to `NO_TRAIN_AT_OFFICE` — told to a player looking straight at a train
* on their own A/D track, which reads as a broken game rather than a rule. Measured over 60
* solitaire games it fired 51 times with a coach train in front of the player: 27 with nobody
* waiting to travel, 24 with passengers waiting and every coach already full.
*/
const f = facilityAt(s, player, at);
if (!f || f.kind !== 'passenger') return 'NO_SUCH_FACILITY';
if (dir === 'board') {
if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return 'NO_PASSENGERS_WAITING';
return 'NO_EMPTY_COACH';
}
if (f.inboundBox.length >= f.capacity.inbound) return 'INBOUND_BOX_FULL';
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return 'NO_EMPTY_COACH_IN_YARD';
return 'NO_LOADED_COACH';
}
// ---------------------------------------------------------------------------
@@ -790,17 +813,31 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
}
// -- Load / Unload --------------------------------------------------------
case 'porter.board':
/**
* A WHISTLE POST HAS NO PORTERS AT ALL, which is not the same as having used them.
*
* The Office card carries a passenger facility at every tier so that an upgrade is a property
* change rather than a card swap — but a Whistle Post's has `porters: 0`, so this fell into
* `RESOURCE_SPENT`, "all Porters already used this Stage". A player who had used nothing was
* told they had spent it all, when the answer was to upgrade the Office. 15 times in 60 games.
*/
case 'porter.board': {
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY';
if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT';
return canBoard(s, player, i.at) ? null : passengerRefusal(s, player);
const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY';
if (f.porters < 1) return 'NO_PORTERS_HERE';
if (portersLeft(f) < 1) return 'RESOURCE_SPENT';
return canBoard(s, player, i.at) ? null : passengerRefusal(s, player, i.at, 'board');
}
case 'porter.detrain':
case 'porter.detrain': {
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY';
if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT';
return canDetrain(s, player, i.at) ? null : passengerRefusal(s, player);
const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY';
if (f.porters < 1) return 'NO_PORTERS_HERE';
if (portersLeft(f) < 1) return 'RESOURCE_SPENT';
return canDetrain(s, player, i.at) ? null : passengerRefusal(s, player, i.at, 'detrain');
}
case 'laborer.startLoad': {
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
+6 -1
View File
@@ -107,7 +107,12 @@ export type GameEvent =
*/
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean }
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
| { type: 'trainCompleted'; trainNumber: number; consist: RollingStock[] }
/**
* The train ran the length of the Division and left it. `side` is the Division Point it left by,
* which is what the announcement needs — and every player scores 1 for it, so this event is
* always followed by one `revenueChanged` per player.
*/
| { type: 'trainCompleted'; trainNumber: number; isExtra: boolean; side: 'east' | 'west'; consist: RollingStock[] }
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
| { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number }
+15 -1
View File
@@ -161,7 +161,21 @@ export type RejectionCode =
| 'NOT_A_GRADE'
| 'TRAIN_ON_CARD'
| 'CONSIST_ORDER'
| 'NO_TRAIN_AT_OFFICE';
| 'NO_TRAIN_AT_OFFICE'
/**
* WHY PASSENGER WORK WAS REFUSED, told apart.
*
* All three used to come back as `NO_TRAIN_AT_OFFICE` — said while a train was standing at the
* Office, which reads as a broken game rather than a rule. Measured over 60 solitaire games, the
* fallback fired 51 times with a coach train in front of the player: 27 with nobody waiting to
* travel, 24 with passengers waiting and every coach on the train already full.
*/
| 'NO_PORTERS_HERE'
| 'NO_PASSENGERS_WAITING'
| 'NO_EMPTY_COACH'
| 'NO_LOADED_COACH'
| 'INBOUND_BOX_FULL'
| 'NO_EMPTY_COACH_IN_YARD';
export type Rejection = { code: RejectionCode; message: string };
+6
View File
@@ -300,6 +300,12 @@ export type CrewTray = {
* once, so there is no later visit to hang it on.
*/
speechMade?: boolean;
/**
* Arrived expedited this Stage, so it departs at the END of the Stage rather than laying over to
* the next one (Q3). Set on arrival; cleared when `shiftChange` attempts the departure — after
* Load/Unload, which is what gives the Porters their one Stage with the train.
*/
departsThisStage?: boolean;
};
// ---------------------------------------------------------------------------
+71 -26
View File
@@ -269,11 +269,13 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
text: `Train ${e.trainNumber} DIVERTED to ${e.to} — ${e.reason}`,
};
case 'trainCompleted':
// The one line in the log that is good news for everybody, so it is not 'quiet'.
return {
tone: 'quiet',
tone: 'good',
text:
`Train ${e.trainNumber} finished its run and left the Division carrying ` +
`${carsLabel(e.consist)} — the crew is free again`,
`Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` +
`${e.side === 'east' ? 'Eastern' : 'Western'} Division Point carrying ${carsLabel(e.consist)}. ` +
`All players get 1 Revenue. The crew is free again.`,
};
// -- freight agent
@@ -533,42 +535,82 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
}
/**
* WHY THE PASSENGERS ARE NOT GETTING OFF.
* WHY THE PASSENGERS ARE NOT MOVING — boarding as well as de-training.
*
* Reported: "I can't figure out how to have a train with three passenger coaches unload all three
* at my depot." You cannot — a Depot has ONE Porter and ONE red slot, so it works one coach a
* Stage and then needs a Freight Agent action to clear the box before the next. A Station does two
* and a Terminal three. That is the Office ladder doing its job, and nothing on screen said so.
* Reported first as "I can't figure out how to have a train with three passenger coaches unload
* all three at my depot." You cannot — a Depot has ONE Porter and ONE red slot, so it works one
* coach a Stage and then needs a Freight Agent action to clear the box before the next. A Station
* does two and a Terminal three. That is the Office ladder doing its job, and nothing on screen
* said so.
*
* §9.2 also requires an empty coach in the DIVISION YARD to swap into the train, which is a
* requirement the engine only started enforcing recently and which no panel mentions at all.
* Reported again as "passenger trains arrive at my Office and move on before I can load or unload
* them", which turned out to be three separate silences. This block only looked at trains carrying
* a LOADED coach, so a train arriving to PICK UP said nothing at all. A Whistle Post's Office card
* carries a passenger facility with zero Porters, so the engine answered `RESOURCE_SPENT` — "all
* Porters used" — to a player who had used none. And an expedited train leaves at the end of the
* Stage it arrives, so its one Load/Unload phase is now-or-never and nothing said which trains
* those are.
*
* §9.2 also requires an empty coach in the DIVISION YARD to swap into the train, which no panel
* mentioned at all.
*/
const office = area.grid.get(coordKey(area.officeCoord));
const pf = office?.facility;
if (pf && pf.kind === 'passenger') {
const waiting = area.adOccupancy
const coachTrains = area.adOccupancy
.map((id) => s.trays.get(id))
.filter((t) => t?.consist.some((c) => c.type === 'coach' && c.loaded));
if (waiting.length > 0) {
const reasons: string[] = [];
if (portersLeft(pf) < 1) {
reasons.push(
.filter((t): t is NonNullable<typeof t> => !!t && t.consist.some((c) => c.type === 'coach'));
if (coachTrains.length > 0) {
const label = (t: (typeof coachTrains)[number]): string =>
t.trainNumber === null ? 'the local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`;
const say = (why: string, severity: Impediment['severity'] = 'waiting'): void => {
out.push({ where: 'Office — passengers', why, severity });
};
const boarding = pf.outboundBox.some((c) => c.type === 'coach' && c.loaded);
const emptyOnTrain = coachTrains.some((t) => t.consist.some((c) => c.type === 'coach' && !c.loaded));
const loadedOnTrain = coachTrains.some((t) => t.consist.some((c) => c.type === 'coach' && c.loaded));
if (pf.porters < 1) {
say(
'this Office has NO Porters — a Whistle Post is not a Passenger Facility (§9) and cannot ' +
'work passengers at all. Upgrade it to a Depot or better.',
'stuck',
);
} else if (portersLeft(pf) < 1) {
say(
`all ${pf.porters} Porter${pf.porters === 1 ? '' : 's'} used this Stage — one works one coach, ` +
'and the Office tier is the Porter count (Depot 1, Station 2, Terminal 3)',
);
} else {
if (boarding && !emptyOnTrain) {
say('passengers are waiting to board, but every coach standing here is already full');
}
if (!boarding && !loadedOnTrain) {
say('nobody waiting to travel and no loaded coach to set down — stock the platform with a Freight Agent action');
}
if (loadedOnTrain && pf.inboundBox.length >= pf.capacity.inbound) {
say(
`the red Unloading box is full (${pf.capacity.inbound} slot${pf.capacity.inbound === 1 ? '' : 's'}) — ` +
'a Freight Agent action clears it, which costs a whole Local Operations turn',
);
}
if (loadedOnTrain && !s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) {
say('no EMPTY coach in the Division Yard to swap into the train (§9.2 requires one)');
}
}
if (pf.inboundBox.length >= pf.capacity.inbound) {
reasons.push(
`the red Unloading box is full (${pf.capacity.inbound} slot${pf.capacity.inbound === 1 ? '' : 's'}) — ` +
'a Freight Agent action clears it, which costs a whole Local Operations turn',
// NOW OR NEVER. An expedited train leaves at the end of this Stage, so this is the only
// Load/Unload phase it will ever stand for — the exact thing the playtest report was about.
for (const t of coachTrains) {
if (!t.departsThisStage) continue;
say(
`${label(t)} is EXPEDITED — it leaves at the END of this Stage, so this is the only ` +
'Load/Unload phase it will stand for. Work it now or not at all.',
'risk',
);
}
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) {
reasons.push('no EMPTY coach in the Division Yard to swap into the train (§9.2 requires one)');
}
for (const why of reasons) {
out.push({ where: 'passengers waiting to de-train', why, severity: 'waiting' });
}
}
}
@@ -645,6 +687,9 @@ export function cuesFor(events: readonly GameEvent[]): string[] {
// The 1D12 that sets a train's departure Stage. It is the one roll a player makes and it landed
// silently, so the card was gone and the answer to "when does it run?" was a line of history.
if (e.type === 'trainScheduled') out.push('schedule');
// A train running the whole length of the Division pays every player, and it is the one event
// nobody made happen on the turn it lands. It should be heard, not found in the log.
if (e.type === 'trainCompleted') out.push('completed');
if (e.type === 'stageBegan') {
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening
// and has nothing behind it. A Day boundary rings the bell only: sounding both would collide,
+16 -1
View File
@@ -116,6 +116,12 @@ export type Game = {
* clear it when the card is played — no element carries the class once the card leaves the hand.
*/
justDrawn: CardId | null;
/**
* A one-line announcement to flash, once. Drained like `scheduled` rather than read like `Frame`,
* because it marks a MOMENT — the only event in the game that scores for everybody without anyone
* having taken a turn to cause it.
*/
announced: string | null;
};
/** How each intent kind is introduced in the action list, in the order they should appear. */
@@ -150,7 +156,7 @@ export const SOLO_PLAYER = 'Solitaire';
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null };
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null };
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
// first, then let the clock take over.
game.log.push({ text: 'Game Begins', tone: 'start' });
@@ -685,6 +691,15 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
for (const e of events) if (e.type === 'trainScheduled') game.scheduled = e.slot;
// Which card just came into hand, so the row can badge it. Last one wins for the same reason.
for (const e of events) if (e.type === 'cardDrawn') game.justDrawn = e.cardId;
// A train that ran the whole Division pays EVERY player, and nobody did anything on this turn to
// make it happen — so it is announced rather than left to be found in the log.
for (const e of events) {
if (e.type === 'trainCompleted') {
game.announced =
`Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` +
`${e.side === 'east' ? 'Eastern' : 'Western'} Division Point. All players get 1 Revenue.`;
}
}
// Keep the log bounded; the full history lives in `history` and can be replayed.
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
}
+14
View File
@@ -459,6 +459,20 @@ function render(): void {
}
lastPhase = f.phaseKey;
/**
* A train completing its run pays every player and nobody took a turn to cause it, so it is said
* out loud rather than left in the log. Drained, so it shows once and does not re-fire on a redraw.
*/
const announcement = session.takeAnnouncement();
if (announcement) {
const el = $('announce');
el.textContent = announcement;
el.className = 'shown';
window.setTimeout(() => {
if (el.textContent === announcement) el.className = '';
}, 4200);
}
renderDistrict(f);
renderActions(menu, f, justSet);
renderUndo();
+9
View File
@@ -88,6 +88,14 @@ button.cardact.discard{color:#d6b48a}
box-shadow:0 6px 22px rgba(0,0,0,.5)}
#phasenote.shown{opacity:1;transform:translateX(-50%) translateY(0)}
@media(prefers-reduced-motion:reduce){#phasenote{transition:none}}
/* A train completing its run pays EVERY player, so it is announced rather than left to the log —
green like a revenue gain, and its own element so it cannot fight the phase note for the slot. */
#announce{position:fixed;left:50%;top:14px;transform:translateX(-50%) translateY(-14px);z-index:41;
background:#16301f;border:1px solid #4c9a6a;color:#c8f0d8;border-radius:14px;padding:7px 18px;
font-size:14px;font-weight:700;opacity:0;pointer-events:none;transition:opacity .18s,transform .18s;
box-shadow:0 6px 22px rgba(0,0,0,.5);max-width:min(680px,90vw);text-align:center}
#announce.shown{opacity:1;transform:translateX(-50%) translateY(0)}
@media(prefers-reduced-motion:reduce){#announce{transition:none}}
/* The die's answer, said where the player is looking. Green like a revenue gain — it is a good
thing that just happened — and it disappears with the next render, as a moment should. */
.scheduled{display:block;font-size:11.5px;margin:0 0 7px;padding:3px 9px;border-radius:5px;
@@ -185,6 +193,7 @@ ul.blocked li{padding:2px 0}
<!-- Announces a phase CHANGE. The turn chart says where you are; this says that it moved, which is
what you miss when the automatic phases run between two clicks. -->
<div id="phasenote"></div>
<div id="announce"></div>
<main>
<div>
+9
View File
@@ -86,6 +86,8 @@ export type Session = {
takeCues(): string[];
/** The timetable slot the last 1D12 filled, once. Draining. */
takeScheduled(): number | null;
/** A one-line announcement to flash, once. Draining. */
takeAnnouncement(): string | null;
/** The card most recently drawn into this seat's hand. Persists until another draw replaces it. */
justDrawn(): string | null;
};
@@ -146,6 +148,11 @@ export function createLocalSession(seed: number, config?: GameConfig): LocalSess
game.scheduled = null;
return slot;
},
takeAnnouncement: () => {
const text = game.announced;
game.announced = null;
return text;
},
justDrawn: () => game.justDrawn,
seed: () => game.seed,
@@ -159,6 +166,7 @@ export function createLocalSession(seed: number, config?: GameConfig): LocalSess
back.cues.length = 0;
back.scheduled = null;
back.justDrawn = null;
back.announced = null;
changed();
return true;
},
@@ -166,6 +174,7 @@ export function createLocalSession(seed: number, config?: GameConfig): LocalSess
game = fromSave(save);
// Restoring replays the whole history and re-records every draw; none of it is news.
game.justDrawn = null;
game.announced = null;
changed();
},
};
+11
View File
@@ -124,6 +124,17 @@ export function playCue(cue: string): void {
return;
}
if (cue === 'completed') {
// A train clearing the Division and whistling off: two long horn notes falling away, with the
// second quieter and lower so it reads as distance rather than repetition. Longer than any other
// cue on purpose — it is the only one that pays every player, and it should land as an event.
tone(t, 392, 0.55, 0.13, 'sawtooth', 370);
tone(t, 262, 0.55, 0.09, 'triangle', 247);
tone(t + 0.42, 330, 0.75, 0.08, 'sawtooth', 300);
tone(t + 0.42, 220, 0.75, 0.055, 'triangle', 200);
return;
}
if (cue === 'day') {
// The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep.
for (const at of [t, t + 0.22]) {
+66 -24
View File
@@ -332,7 +332,7 @@ describe('ABS Signals amend the collision rule', () => {
// ---------------------------------------------------------------------------
describe('one Revenue for every train that clears your section', () => {
describe('one Revenue to EVERY player when a train completes its run', () => {
/** A train sitting on the A/D track, made up and ready to highball. */
const readyToLeave = (s: GameState, direction: 'east' | 'west' = 'east'): string => {
const id = 'leaving';
@@ -348,35 +348,61 @@ describe('one Revenue for every train that clears your section', () => {
return id;
};
it('pays the owner when a train departs onto the Mainline', () => {
it('pays nothing for merely leaving the Office', () => {
/**
* THE RULE CHANGED IN v0.4.2. It used to pay 1 to the Office a train departed, which meant a
* five-Office railroad paid five times for one train and paid most to whoever it happened to
* pass first. Departing is now worth nothing on its own.
*/
const s = game();
const before = s.players[0]!.revenue;
readyToLeave(s, 'east');
const r = advance(s);
advance(s);
assert.equal(s.players[0]!.revenue, before + 1, 'clearing a train earned nothing');
assert.ok(
r.events.some((e) => e.type === 'revenueChanged' && e.reason === 'a train cleared your section'),
'no revenue event was emitted for the departure',
);
// Paid ONCE. Running off the end of the Division later is not a second departure — that far
// Division Point belongs to whoever is seated at it.
const after = s.players[0]!.revenue;
for (let i = 0; i < 8; i++) { s.movedThisPhase = new Set(); s.clock.phase = 'mainline'; advance(s); }
assert.equal(s.players[0]!.revenue, after, 'the same train was paid for a second time on its way out');
assert.equal(s.players[0]!.revenue, before, 'departing the Office still paid');
});
it('pays nothing for a local crew, which is switching rather than departing', () => {
it('pays every player once the train runs off the end of the Division', () => {
const s = game();
const before = s.players.map((p) => p.revenue);
readyToLeave(s, 'east');
// Run the Mainline Phase until the train has crossed the Division and left it.
const seen: typeof s.players[number]['revenue'][] = [];
let completed: { type: string; side?: string } | undefined;
for (let i = 0; i < 20 && !completed; i++) {
s.movedThisPhase = new Set();
s.clock.phase = 'mainline';
const r = advance(s);
completed = r.events.find((e) => e.type === 'trainCompleted');
if (completed) {
assert.equal(completed.side, 'east', 'an eastbound train left by the wrong Division Point');
assert.ok(
r.events.some((e) => e.type === 'revenueChanged' && e.reason === 'a train completed its run'),
'no revenue event was emitted for the completed run',
);
}
seen.push(s.players[0]!.revenue);
}
assert.ok(completed, 'the train never left the Division');
assert.deepEqual(s.players.map((p) => p.revenue), before.map((r) => r + 1), 'not every player was paid');
// Paid ONCE — the train is gone, so there is nothing left to pay again.
const after = s.players.map((p) => p.revenue);
for (let i = 0; i < 8; i++) { s.movedThisPhase = new Set(); s.clock.phase = 'mainline'; advance(s); }
assert.deepEqual(s.players.map((p) => p.revenue), after, 'the same run was paid twice');
});
it('pays nothing for a local crew, which is switching rather than running', () => {
const s = game();
const before = s.players[0]!.revenue;
const id = readyToLeave(s, 'east');
s.trays.get(id)!.trainNumber = null;
advance(s);
for (let i = 0; i < 20; i++) { s.movedThisPhase = new Set(); s.clock.phase = 'mainline'; advance(s); }
assert.equal(s.players[0]!.revenue, before, 'a switching crew was paid as though it had cleared the section');
assert.equal(s.players[0]!.revenue, before, 'a switching crew was paid as though it had completed a run');
});
});
@@ -391,8 +417,9 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
* Nothing states the rule, which makes it exactly the kind of property a later change to the loop
* would break in silence. These pin it.
*
* It is also what gives the printed **Expedite** rule something to do — an Expedited train is
* granted a second `moveTrain` in the same phase precisely because the general rule forbids it.
* It is also what gives the printed **Expedite** rule something to do — an Expedited train departs
* at the END of the Stage it arrived, precisely because the general rule forbids it departing at
* all. It stands through Load/Unload first, which is what makes its coaches workable.
*/
const arriving = (s: GameState, trainNumber: number, isExtra = false): string => {
const id = 'arriving';
@@ -425,9 +452,13 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
);
});
it('unless its card prints Expedite, which exists to override exactly this', () => {
// Q3. Train 3 prints `expedite`, so it arrives and departs in one Stage — still subject to the
// rest of §8.1, which is why the clearance path is re-entered rather than skipped.
it('unless its card prints Expedite — but it still stands through Load/Unload first', () => {
/**
* Q3, as revised in v0.4.2. An Expedite train leaves the Stage it arrives, but at the END of that
* Stage rather than inside the Mainline Phase. It used to be gone before Load/Unload ran, which
* made the coaches on every coach-carrying express (1/2, 5/6, 19) permanently unworkable —
* measured, Train 2 arrived 32 times in 60 games and stood for Load/Unload in none of them.
*/
const s = game();
assert.equal(trainProfile(3, false)?.rules.expedite, true, 'train 3 should be an Expedite train');
const id = arriving(s, 3);
@@ -435,8 +466,19 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
advance(s);
assert.equal(
s.trays.get(id)!.position.at, 'mainline',
'an Expedite train should leave again the Stage it arrives',
s.trays.get(id)!.position.at, 'grid',
'an Expedite train left inside the Mainline Phase, before Load/Unload could reach it',
);
assert.equal(s.trays.get(id)!.departsThisStage, true, 'it is not flagged to leave at the end of the Stage');
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'it is not standing on an A/D track');
// Run the end of the Stage directly. Load/Unload waits on the player, and the thing under test
// is the departure pass that follows it — not that phase's own input handling.
s.clock.phase = 'shiftChange';
advance(s);
assert.notEqual(
s.trays.get(id)?.position.at, 'grid',
'an Expedite train should be gone by the end of the Stage it arrived',
);
});
});
+21 -7
View File
@@ -543,11 +543,15 @@ describe('the view shows one seat at a time', () => {
});
describe('scoring lands on the right seat', () => {
it('pays the departure Revenue to the Office the train left, not to seat 0', () => {
it('pays EVERY player when a train completes its run, and nobody for merely departing', () => {
/**
* The rule reads "one Revenue for every train that clears YOUR section", and `awardDeparture`
* takes the owner of the departing Office. With one player that is unfalsifiable: seat 0 is the
* only seat there is. This is the test that says so with three.
* THE RULE CHANGED IN v0.4.2. It used to pay 1 to the Office a train departed — which on a
* three-Office railroad paid three separate times for one train, and paid whichever Office the
* train happened to pass first. It pays once now, when the train runs off the end of the
* Division, and it pays everybody: getting a train the whole length is the shared achievement.
*
* With one player this is unfalsifiable, because "everybody" is one person. This is the test
* that says so with three.
*/
const s = game(3);
pinTerrain(s);
@@ -558,10 +562,20 @@ describe('scoring lands on the right seat', () => {
s.movedThisPhase = new Set();
advance(s);
assert.deepEqual(s.players.map((p) => p.revenue), before, 'departing the Office paid somebody');
assert.equal(s.players[2]!.revenue, before[2]! + 1, 'seat 2 was not paid for its own departure');
assert.equal(s.players[0]!.revenue, before[0]!, 'seat 0 was paid for a train it never ran');
assert.equal(s.players[1]!.revenue, before[1]!, 'seat 1 was paid for a train it never ran');
let completed;
for (let i = 0; i < 25 && !completed; i++) {
s.movedThisPhase = new Set();
s.clock.phase = 'mainline';
completed = advance(s).events.find((e) => e.type === 'trainCompleted');
}
assert.ok(completed, 'the train never ran off the end of the Division');
assert.deepEqual(
s.players.map((p) => p.revenue),
before.map((r) => r + 1),
'a completed run did not pay every player exactly 1',
);
});
it('keeps each seat’s Office Area to itself', () => {
+21 -8
View File
@@ -424,24 +424,37 @@ describe('switching accomplishes something (regression)', () => {
// So test the thing the old threshold was a proxy for: motion must convert into work. A crew
// that shuttles for its own sake shows a high ratio here, however few or many Moves it makes.
/**
* 150 games, not 40. At 40 this measured 1.9 productive acts against a floor of 2 and failed,
* while the same bot over 200 games gives 2.6 — the test was under-powered rather than the bot
* broken, and a floor that trips on sample noise trains you to ignore it. Switching is a
* low-count, high-variance activity: a couple of games with a stranded crew move the mean.
* 400 games, not 150 and not 40. This has been under-powered twice: at 40 it measured 1.9
* against a floor of 2, and the fix was 150 on the strength of a 200-game figure of 2.6. Neither
* number survived. Measured decisively at 400 games the bot does **1.76** productive acts, and
* the 150-game sample was reading 2.16 purely on which seeds it happened to include — so the
* floor of 2 had been passing on sample composition rather than on behaviour, which is the exact
* failure mode the previous comment was written to prevent.
*
* Switching is a low-count, high-variance activity: a couple of games with a stranded crew move
* the mean, so the sample has to be big enough that they cannot.
*/
const report = simulate({
games: 150, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
games: 400, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
});
const per = (f: (g: (typeof report.perGame)[number]) => number): number =>
report.perGame.reduce((n, g) => n + f(g), 0) / report.perGame.length;
const moves = per((g) => g.actions.moves);
const work = per((g) => g.actions.drops + g.actions.couples);
assert.ok(work > 2, `only ${work.toFixed(1)} productive acts a game — the crew is doing nothing`);
/**
* 1.2, against 1.48 measured. Lowered from 2 in v0.4.2 for two separate reasons, both recorded
* rather than blended: the old floor was never actually met (1.76 at 400 games), and expedited
* trains now stand at the Office for a Stage instead of passing straight through, which costs
* about 16% of the crew's work because a train on the A/D track and the Office square is in the
* way. That second part is the change doing what it is supposed to do, not a fault — but it IS
* drift, and it is logged in `TODO.md` with the rest of it rather than quietly absorbed here.
*/
assert.ok(work > 1.2, `only ${work.toFixed(2)} productive acts a game — the crew is doing nothing`);
assert.ok(
moves / work < 8,
moves / work < 9,
`${(moves / work).toFixed(1)} Moves per drop or coupling suggests aimless shuttling ` +
`(${moves.toFixed(0)} Moves, ${work.toFixed(1)} productive acts)`,
`(${moves.toFixed(0)} Moves, ${work.toFixed(2)} productive acts)`,
);
});
});