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
+22 -6
View File
@@ -560,6 +560,7 @@ const FACILITY_NAMES: Record<string, string> = {
function facilityView(
card: { geometry: { kind: string; facility?: string }; facility: unknown; modifiers?: string[] },
officeName: string,
viewerSeat: SeatIndex,
): FacilityView | null {
const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
// Passenger facilities were excluded entirely, so the Office's green and red slots never
@@ -581,7 +582,9 @@ function facilityView(
maw: (f.menAtWork ?? []).map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
red: f.inboundBox.map(carLabel),
redCap: f.capacity.inbound,
track: f.industryTrack.cars.map(carLabel),
// Marked when this district made the load: the spotted car is exactly where a player is looking
// when they ask why the Laborer will not unload it.
track: f.industryTrack.cars.map((c) => carLabel(c, viewerSeat)),
laborers: `${laborersLeft(f)}/${f.laborers}`,
porters: `${portersLeft(f)}/${f.porters}`,
canFinish: canFinishHere(f),
@@ -672,7 +675,9 @@ function trainsOnCard(s: GameState, viewerSeat: SeatIndex, key: string): CellVie
out.push({
trayId: id,
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
cars: t.consist.map(carLabel),
// A coach filled at THIS Office reads "loaded coach (loaded here)" — those passengers may not
// alight in the district that boarded them, and the tray is where a player looks for that.
cars: t.consist.map((c) => carLabel(c, viewerSeat)),
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
facing: railFacingOf(t),
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
@@ -728,6 +733,11 @@ function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
return shown.join('; ') + (more > 0 ? ` … and ${more} more distinct` : '');
}
/** " onto Train 8", or nothing at all when the intent names no train (an old save, or one train). */
function onto(s: GameState, trayId: string | undefined, joiner: string): string {
return trayId === undefined ? '' : `${joiner}${trainName(s, trayId)}`;
}
/** One readable line for a single intent. */
export function describeIntent(s: GameState, i: Intent): string {
// X,Y — east/west then north/south, not the internal row/col storage order.
@@ -907,10 +917,16 @@ export function describeIntent(s: GameState, i: Intent): string {
return `advance load in box ${i.box} at ${at(i.at)}`;
case 'laborer.beginUnload':
return `begin unloading car ${i.carIndex} at ${at(i.at)}`;
/**
* NAME THE TRAIN. The action list drops duplicate labels within a crew, and with two trains
* standing at one station "board passengers at (0,0)" describes both — which is half of why the
* v0.4.9d playtest found that picking a train changed nothing. The intent now carries the tray;
* the label has to say so or the second button is thrown away before the menu sees it.
*/
case 'porter.board':
return `board passengers at ${at(i.at)}`;
return `board passengers at ${at(i.at)}${onto(s, i.trayId, ' onto ')}`;
case 'porter.detrain':
return `detrain passengers at ${at(i.at)}`;
return `detrain passengers at ${at(i.at)}${onto(s, i.trayId, ' from ')}`;
case 'newTrain.startExtra': {
const runs = i.trainNumber % 2 === 0 ? 'east' : 'west';
if (i.atSeat === null) {
@@ -1059,7 +1075,7 @@ export function snapshot(
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
else label = geometryLabel(g.geometry);
const fv = facilityView(card as never, officeProfile(area.tier).name);
const fv = facilityView(card as never, officeProfile(area.tier).name, viewerSeat);
if (fv) facilities.push(fv);
cells.push({
@@ -1074,7 +1090,7 @@ export function snapshot(
enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)),
trains: trainsOnCard(s, viewerSeat, key),
adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null,
cars: carsOn(card).map(carLabel),
cars: carsOn(card).map((c) => carLabel(c, viewerSeat)),
standingWest: card.standingWest,
facility: fv,
});