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
+20 -7
View File
@@ -18,7 +18,7 @@ import {
laborersLeft,
movesFor,
ownCutFor,
isTrainCard,
keepReason,
portersLeft,
resolveExtraStart,
selectDestination,
@@ -492,12 +492,22 @@ export type Frame = {
/**
* Whether each hand card may be DISCARDED, in the same order.
*
* §6.2 as ruled by Jesse (Gitea#6): a train card never can be. The player has to be told which
* cards those are, not merely find that a button is missing — that silence is the whole of the
* Gitea#2 complaint, where a blocked platform left the board with nothing to click and no reason.
* Named for the rule rather than for trains, since it answers the question the panel is asking.
* The player has to be told which cards those are, not merely find that a button is missing —
* that silence is the whole of the Gitea#2 complaint, where a blocked platform left the board with
* nothing to click and no reason. Named for the rule rather than for trains, since it answers the
* question the panel is asking.
*/
handDiscardable: boolean[];
/**
* WHY a card may not be discarded, in the same order; `null` where it may.
*
* Carried rather than written on the page because §6.2 now fails for two different reasons
* (Gitea#9): an Extra is never discardable, and a Timetabled train is not discardable only when
* the `discardTimetabled` house rule is off. A panel that hard-codes one sentence tells half the
* players the wrong thing, and a panel that reconstructs the rule is a second implementation of
* it. `keepReason` is the engine's own, so the card says the rule that actually refused.
*/
handKeepWhy: (string | null)[];
deck: number;
/** The face-up card on top of each Department pile — the only one that may be drawn. */
departments: string[];
@@ -1294,7 +1304,8 @@ export function snapshot(
*/
hand: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardName(s, id)),
handWhat: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardDescription(s, id)),
handDiscardable: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => !isTrainCard(s, id)),
handDiscardable: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id) === null),
handKeepWhy: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id)),
deck: s.decks.homeOffice.length,
departments: s.decks.departments.map((pile) => {
const top = pile[pile.length - 1];
@@ -1575,7 +1586,9 @@ export function trainRules(t: {
}
if (p.rules.noPassengerWork) parts.push('NO PASSENGER WORK — Porters may not board or detrain it');
if (p.rules.dropOnly) parts.push('MAY DROP BUT NOT PICK UP — it cannot couple anything');
if (p.rules.pickUpEmptiesOnly) parts.push('EMPTIES ONLY — it may not couple a loaded car');
if (p.rules.pickUpEmptiesOnly) {
parts.push('EMPTIES ONLY — it may not couple a loaded car. A caboose is not a load.');
}
if (p.rules.stopThenExpedite) {
parts.push('STOPS ONCE FOR SPEECHES, then runs expedited from its next Office onward');
}