v0.6.1 — five of six playtest bugs: one button per train, and a load that has to go somewhere

Gameplay testing on 0.4.9d returned six reports. Five are fixed; the sixth could not be
reproduced and is written up in TODO.md with the two questions that would pin it down.

TWO TRAINS AT ONE PLATFORM ANSWERED TO ONE BUTTON. `porter.board` and `porter.detrain`
carried no tray, so there was one button per platform however many trains stood at it and
the reducer filled the first empty coach on the A/D tracks. `check` and the reducer were not
even asking the same question: `check` skipped a train whose card refuses passenger work and
the reducer did not. Both intents now carry an optional `trayId`, one function resolves the
train and the coach for check/execute/reduce alike, `legal.ts` offers one candidate per train,
and the label names it.

A LOAD COULD BE MADE AND BROKEN WITHOUT GOING ANYWHERE. A Freight House could unload the
boxcar it had just loaded; a platform could detrain the passengers it had just boarded. Full
Revenue at both ends for a movement that never happened. Jesse's rule: a load made anywhere in
an Office Area may not be broken anywhere in that Office Area, ever — it has to be carried to
another district. The load carries the seat that made it (`RollingStock.origin`), stripped by
`pooled` at every yard push. Measured at -0.60 +/- 0.10 Revenue a game (t = -6.1) over 400
paired deals: 78 worse, 3 better, 319 unchanged — free Revenue coming off the board, not a nerf.

THE GROCER'S WAREHOUSE SHIPPED AND THE REFINERY RECEIVED. Both were `flow: 'both'` on the
reading that "Freight House" was a collective term for exactly those two, and therefore what
§9.3 described. The engine has dealt a Freight House CARD since before v0.4.9, so §9.3 names
it and the argument goes. The card set agrees: all three Refinery modifiers grant +1 outbound.
Refinery outbound-only, Grocer's inbound-only, Freight House the one two-way industry — which
leaves exactly the one same-district pairing the rule above refuses.

NOT REPRODUCED: cars left behind when backing up over them. Five layouts tried, including cars
spotted at an industry; every one couples the lot. Three are pinned in `apply.test.ts`. One way
to create such cars was closed anyway — `flyingSwitch` wrote its cut past `carsOn`.

Both published replays that had gone dead were re-recorded; a rules change retires a save, and
`harness.test.ts` is what catches it.

The same change ships as v0.4.9e on the 0.4.9 line, branched from the v0.4.9d commit — the engine
files these fixes touch are identical across the two lines, so the patch applied cleanly both ways.

Also carries the two "Queued 2026-08-22, from playing on StartOS" TODO items that were staged
before this work started (Games in Progress readability, and getting back into a game after
losing a browser). They are notes, and items 9-12 below them are numbered against them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nbvwWMef8CuEP6t5cgkTv
This commit is contained in:
Jesse.Markowitz
2026-08-21 23:54:24 -04:00
co-authored by Claude Opus 5
parent 40f07b0710
commit 83a5450866
25 changed files with 2853 additions and 1811 deletions
+8 -5
View File
@@ -39,7 +39,7 @@ import type { GameEvent } from './events.ts';
import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts';
import { legalActions } from './legal.ts';
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
import { coordKey, freshTurns, playerAtSeat, playerLeftOf, subdivisions, totalRevenue, turnOf } from './state.ts';
import { coordKey, freshTurns, playerAtSeat, playerLeftOf, pooled, subdivisions, totalRevenue, turnOf } from './state.ts';
export type AdvanceResult = {
events: GameEvent[];
@@ -959,9 +959,11 @@ function collide(
consist: [...tray.consist],
});
// Gap 2c — engines and cabooses return to the Division Yard, everything else to Classification.
// `pooled` because a car reaching a yard is back in the common supply: the load's origin stamp
// (state.ts) belongs to the load, not to the car that happened to be carrying it.
for (const car of tray.consist) {
if (car.type === 'caboose') s.yards.divisionYard.push(car);
else s.yards.classificationYard.push(car);
if (car.type === 'caboose') s.yards.divisionYard.push(pooled(car));
else s.yards.classificationYard.push(pooled(car));
}
s.trays.delete(id);
s.freeTrays.push(id);
@@ -1031,9 +1033,10 @@ function retireTrain(
side: Direction,
events: GameEvent[],
): void {
// `pooled` — see `trainsDestroyed` above; a load's origin stamp does not survive the yard.
for (const car of tray.consist) {
if (car.type === 'caboose') s.yards.divisionYard.push(car);
else s.yards.classificationYard.push(car);
if (car.type === 'caboose') s.yards.divisionYard.push(pooled(car));
else s.yards.classificationYard.push(pooled(car));
}
s.trays.delete(id);
s.freeTrays.push(id);