v0.7.1 — a caboose is not a load, a Day that says it ended, and a train you may throw away

Four issues off the Gitea tracker, all of them things a player saw at the board. Reasoning for
every item, and what was verified how: CHANGELOG.md.

- Gitea#8: X22 Pee-Dee refused every caboose, including the one it was made up with, so setting it
  out stranded the train. All six cabooses are minted loaded because §2.2's "coloured is loaded,
  white is empty" doubles as a piece count in the supply table; one read of the flag took that
  literally. A caboose carries the crew, not freight, so it is never a load.
- Gitea#10: a Day turns over inside the phases that run themselves, so it passes between one click
  and the next — and both transient signals fade before a player reading the board notices. A modal
  stops and waits, carrying the standings, the Days left and the combined target. Suppressed on the
  first frame, on Undo stepping back across a rollover, and on the Day the game ends.
- Gitea#9, which SUPERSEDES Gitea#6 from three days ago: a Timetabled train may be tossed face-up
  to a Department slot, where a rival may pick it up — the second half of the ruling needed no code,
  since that is where every discard already goes. An Extra still may not. A New Game setting on this
  line (discardTimetabled, on by default), the plain rule on the 0.4.9 line.
- Gitea#2 is not an engine bug: the rules are implemented exactly, and running the coach pool dry is
  Jesse's ruling to keep — "part of the strategy". What was wrong is that the game said nothing. A
  blocked platform now gives its reason, from the engine's own predicate, including how many coaches
  are stranded in Classification and what brings them back.

The same four ship as v0.4.9g on the playtest line.

Closes #2
Closes #8
Closes #9
Closes #10

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLnYR4XtXQNamYJXGYT8oC
This commit is contained in:
Jesse.Markowitz
2026-08-25 10:46:22 -04:00
co-authored by Claude Opus 5
parent 603d38602c
commit 441447648d
17 changed files with 977 additions and 115 deletions
+113 -3
View File
@@ -18,7 +18,7 @@
import { MAX_CONSIST } from '../engine/content.ts';
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
// ---------------------------------------------------------------------------
@@ -524,14 +524,124 @@ export type Impediment = { where: string; why: string; severity: 'stuck' | 'wait
* This is the panel that should answer the standing questions: whether facilities jam, whether
* trains are held for want of a crew, whether the Office is about to cause a collision.
*/
/** `coordKey`'s inverse — the grid is keyed by string and the engine predicates take coordinates. */
function uncoordKey(key: string): GridCoord {
const [row, col] = key.split(',').map(Number);
return { row: row ?? 0, col: col ?? 0 };
}
/**
* One of `passengerRefusal`'s codes, in words a player can act on.
*
* `NO_EMPTY_COACH_IN_YARD` gets the longest answer because it is the one that looks like a broken
* game: the Division Yard is visibly full of cars, and the single type that has run out is the one
* §9.2 needs. Where the missing coaches ARE, and the condition that brings them back, is the whole
* of what the player needs to know — §2.2 returns the Classification Yard only when the Division
* Yard is bare, so a yard with fifty freight cars in it will not refill for a long time.
*/
function passengerReason(
s: GameState,
player: PlayerIndex,
at: GridCoord,
dir: 'board' | 'detrain',
): string {
const code = passengerRefusal(s, player, at, dir);
switch (code) {
case 'NO_TRAIN_AT_OFFICE':
return dir === 'board'
? 'passengers waiting, no train at the platform to take them'
: 'no train at the platform';
case 'NOT_A_TERMINAL':
return 'the only train here stops at Terminals only — Porters may not work it at this Office';
case 'NO_PASSENGER_WORK':
return 'the only train here is one its card bars Porters from working';
case 'NO_EMPTY_COACH':
return 'passengers waiting, but every coach on the train is already full';
case 'INBOUND_BOX_FULL':
return 'arrivals aboard, but the red Unloading slots are all occupied';
case 'LOADED_IN_THIS_DISTRICT':
return 'the loaded coaches all boarded here — passengers must be carried to another Office ' +
'Area before they can alight';
case 'NO_EMPTY_COACH_IN_YARD': {
const stuck = s.yards.classificationYard.filter((c) => c.type === 'coach').length;
const total = s.yards.divisionYard.length;
return (
'arrivals aboard, but §9.2 needs a white empty coach from the Division Yard to swap in and ' +
`there is none left${stuck > 0 ? ` — ${stuck} ${stuck === 1 ? 'coach is' : 'coaches are'} in the Classification Yard` : ''}. ` +
`Classification returns only when the Division Yard is bare, and it still holds ${total} cars.`
);
}
default:
return `Porters cannot work here (${code})`;
}
}
export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[] {
const out: Impediment[] = [];
const area = areaOf(s, player);
for (const [key, card] of area.grid) {
const f = card.facility;
if (!f || f.kind !== 'freight') continue;
const name = card.geometry.kind === 'facility' ? card.geometry.facility : 'facility';
if (!f) continue;
/**
* A Freight Facility names itself off its own card; a Passenger Facility does NOT — it rides on
* the `office` card, so `geometry.kind` is `'office'` and it fell through to the literal
* "facility". Every passenger impediment therefore read `facility 0,0`, next to a freight row
* saying `mineTipple 1,-3`. The Office Area's tier is the name it should carry, and there is
* exactly one Office per Area, so `area.tier` is that card's own.
*/
const name =
card.geometry.kind === 'facility'
? card.geometry.facility
: card.geometry.kind === 'office'
? area.tier
: 'facility';
/**
* WHY THE PORTERS ARE STANDING THERE (Gitea#2).
*
* "Note that the sparrow (with two loaded coaches) pulled into the station. There are two
* passengers on the platform. Four porters. My thought was to unload two and load two. I never
* get the chance to load the last two."
*
* The engine was right — §9.2 needs a white coach out of the Division Yard to de-train into,
* §2.2 returns the Classification Yard only when the Division Yard is BARE, and the Division
* Yard was one empty coach short with eight more sitting in Classification unable to come back.
* Jesse's ruling is that the shortage stays: "it is possible to run out — that's part of the
* strategy." What was missing was any way to SEE it. A Porter action that cannot be taken is
* simply absent from the menu, and this panel — the one that answers "why is nothing moving?" —
* covered freight facilities only, so the platform had nothing to say for itself at all.
*
* The reason comes from `passengerRefusal`, the engine's own, so what is on screen is the rule
* that actually refused rather than a second guess at it.
*/
if (f.kind === 'passenger') {
if (portersLeft(f) > 0) {
const coord = uncoordKey(key);
// Passengers standing on the platform with nothing carrying them away.
if (f.outboundBox.some((c) => c.type === 'coach' && c.loaded) && !canBoard(s, player, coord)) {
out.push({
where: `${name} ${key}`,
why: passengerReason(s, player, coord, 'board'),
severity: 'waiting',
});
}
// A coach full of arrivals that cannot be emptied.
const arriving = area.adOccupancy.some((id) =>
s.trays.get(id)?.consist.some((c) => c.type === 'coach' && c.loaded && c.origin !== seatOf(s, player)),
);
if (arriving && !canDetrain(s, player, coord)) {
out.push({
where: `${name} ${key}`,
why: passengerReason(s, player, coord, 'detrain'),
severity: 'stuck',
});
}
}
continue;
}
if (f.kind !== 'freight') continue;
const want = facilityCarType(f);
// A load that cannot move, with Laborers standing by, is the worst state a facility reaches: