Extras that must run loaded, and a circus paid per district (Gitea#13)

"I've redefined some of the extra trains that they have to run full boxcars —
military trains, circus trains, etc. If not loaded, then empty, and if none
available, run without."

MAKE-UP. X17 Campaign, X18 Circus and X19 Military carry `mustRunLoaded`. It is a
preference order rather than a flat requirement, so the rule is asked of the
DIVISION YARD: an empty is refused only while the yard can still supply a loaded
car this train would accept, and once it cannot, the empty is legal and the train
may still depart short. Per category, since that is the slot the car competes for
— a loaded coach is no reason to refuse an empty boxcar.

SCORING, per Jesse's ruling (2026-08-29): "once per stop in an office area. In a
multiplayer game, each player could score if the circus stops in their area." So
`stopPointClaimed` (a boolean, once per game) becomes `stopPointSeats` (the seats
already paid). A Circus touring three districts is paid three times; one parked in
the same district all game is paid once. The other half of the ruling — "if the
circus train gets recycled and played a second time as a second extra, then it
could again score points later too" — needs no code: a train is made up onto a
fresh tray every time, so a re-played Extra starts with an empty list.

The point now requires the train to be FULLY LOADED, meaning every non-caboose car
loaded. A coach counts as loaded when occupied, which is what makes this the right
test for the Campaign Train: X17 carries one coach and no freight, so "fully
loaded" is exactly "the candidate is aboard". X17 also GAINS the per-stop point —
it had `stopThenExpedite` and no scoring rule at all, and Jesse's "credit for a
circus or campaign train (one point per stop)" says it should score.

TWO BUGS FIXED ALONG THE WAY:

  - `ConsistSpec.emptiesOnly` was declared on X13 Appleseed, RENDERED to the player
    as "(empties only)" by both web/game.ts and sim/view.ts, and enforced by
    nothing — `acceptsCar` never read it, so the Appleseed could be made up loaded
    while its own card said otherwise. It is the same rule as this issue pointing
    the other way, and it would have been perverse to add one and leave the other.
  - Setting up out on the Mainline paid a point to PLAYER 0 whoever was playing:
    `playerAtSeat` needs a seat, off the grid there is none, and the fallback was
    `0`. Scoping the rule to Office Areas is what the ruling says and removes the
    misattribution rather than patching it.

Both loading rules exempt the caboose: every caboose in ROLLING_STOCK_SUPPLY is
minted loaded, so an unexempted rule would bar the one car a consist lists by name.

`trainNeedingCars` now asks the full question per car rather than the shape
question. It shares its predicate with `check` precisely to avoid the stall its own
comment describes, and the shape question stopped being the same question: an
empties-only train facing a yard of loaded cars would have been told a car was
available and then refused every one.

The two published replays that stopped replaying under the new rules were retired
and re-recorded with save-replay.ts, which verifies each candidate before writing
it. That is what test/harness.test.ts is for and what its comment prescribes.

846 tests pass.

Closes #13

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
This commit is contained in:
Jesse.Markowitz
2026-08-29 06:44:13 -04:00
co-authored by Claude Opus 5
parent 9ae8e9e09d
commit 5e34c73b16
9 changed files with 2127 additions and 1974 deletions
+51 -14
View File
@@ -50,6 +50,31 @@ export type AdvanceResult = {
const NIGHT_STAGES = new Set([1, 2, 3, 11, 12]);
/**
* IS EVERY CAR ON THIS TRAIN LOADED? (Gitea#13)
*
* "You only get credit for a circus or campaign train (one point per stop) if you have it fully
* loaded. Not much of a circus if all the cars are empty."
*
* A COACH COUNTS AS LOADED WHEN IT IS OCCUPIED, which is what makes this the right test for the
* Campaign Train: X17 carries one coach and no freight, so "fully loaded" is precisely "the
* candidate is aboard" (Jesse's ruling, 2026-08-29). The engine already models an occupied coach
* as `loaded`, so no second notion is introduced here.
*
* A CABOOSE IS EXEMPT, and it costs nothing to say so: every caboose in `ROLLING_STOCK_SUPPLY` is
* minted `loaded: true` — there is no empty one — so including it would change no outcome today.
* It is excluded anyway because a caboose is crew space rather than payload, and a supply table
* that grew an empty caboose should not silently start voiding circus points.
*
* AN EMPTY TRAIN IS NOT FULLY LOADED. A Circus that departed short and carries nothing at all earns
* nothing: `every` on an empty list is vacuously true, which would pay the emptiest train of the
* lot, so the length is tested first.
*/
function fullyLoaded(tray: CrewTray): boolean {
const payload = tray.consist.filter((c) => c.type !== 'caboose');
return payload.length > 0 && payload.every((c) => c.loaded);
}
function movesForStage(s: GameState): number {
return s.config.optionalRules.reducedVisibility && NIGHT_STAGES.has(s.clock.stage)
? MOVES_PER_LOCAL_OPS_NIGHT
@@ -420,33 +445,45 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
const where = tray.position;
const moved = moveTrain(s, id, tray, events);
/**
* X18 CIRCUS TRAIN — "one turn stopped on any track (circus set-up) earns 1 point".
* X18 CIRCUS / X17 CAMPAIGN — a Stage spent set up in somebody's Office Area earns a point.
*
* The flag was declared on the profile and read NOWHERE, so the one card in the deck that pays
* The flag was declared on the profile and read NOWHERE, so the one card in the deck that paid
* for standing still paid nothing: reported from a playtest where TX18 sat on a siding for a
* full Stage and no point arrived. Claimed once — an Extra runs once and is gone.
* full Stage and no point arrived.
*
* ONCE PER OFFICE AREA (Gitea#13, Jesse 2026-08-29): "once per stop in an office area. In a
* multiplayer game, each player could score if the circus stops in their area." So a Circus
* touring three districts is paid three times and one parked in the same district all game is
* paid once, which `stopPointSeats` records per seat.
*
* ONLY IN AN OFFICE AREA. It used to pay for standing on the Mainline or at a Division Point
* too, and misattributed both: `playerAtSeat` needs a seat, and off the grid there is none, so
* the fallback handed the point to PLAYER 0 wherever the train happened to be. Scoping the rule
* to Office Areas is what Jesse's ruling says and it removes that bug rather than patching it.
*
* FULLY LOADED, or nothing. "Not much of a circus if all the cars are empty" — see
* `fullyLoaded` below for what that means for a train whose only car is a coach.
*
* "Stopped" is measured against the Mainline Phase: the train attempted to move and stayed where
* it was. A train that is still in the district when the phase runs has not moved either, which
* is the circus setting up on a siding rather than crossing the Division.
*/
if (!tray.stopPointClaimed && trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopEarnsPoint) {
if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopEarnsPoint) {
const stillThere =
tray.position.at === where.at &&
(tray.position.at !== 'mainline' || where.at !== 'mainline' || tray.position.index === where.index) &&
(tray.position.at !== 'grid' ||
where.at !== 'grid' ||
(tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col));
if (stillThere) {
tray.stopPointClaimed = true;
// Bound as one value so the grid case narrows: `tray.position` is a union, and testing a
// `seat` extracted from it does not tell the compiler which member it came from.
const at = tray.position.at === 'grid' ? tray.position : null;
const alreadyPaidHere = at !== null && (tray.stopPointSeats ?? []).includes(at.seat);
if (stillThere && at !== null && !alreadyPaidHere && fullyLoaded(tray)) {
tray.stopPointSeats = [...(tray.stopPointSeats ?? []), at.seat];
// 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;
const label =
tray.position.at === 'grid'
? `(${tray.position.coord.col},${tray.position.coord.row})`
: tray.position.at === 'mainline'
? `Mainline card ${tray.position.index}`
: `the ${tray.position.side} Division Point`;
const owner = playerAtSeat(s, at.seat);
const label = `(${at.coord.col},${at.coord.row})`;
events.push({ type: 'trainStoodStill', trainNumber: tray.trainNumber ?? 0, where: label });
const p = s.players[owner];
if (p) {
@@ -456,7 +493,7 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
player: owner,
delta: 1,
total: p.revenue,
reason: 'circus set-up — a Stage spent standing still',
reason: 'set up in the district — a Stage spent standing still, fully loaded',
});
}
}
+64 -7
View File
@@ -1159,7 +1159,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
// This was not enforced at all: any car could be added in any quantity, so Train 9 "Heavy
// Freight" — a card calling for 3 freight AND a caboose — was made up with four hoppers and
// no caboose. Fewer is allowed; more, or of the wrong category, is not.
return acceptsCar(tray, i.carType) ? null : 'NO_SUITABLE_CAR';
return acceptsCar(tray, i.carType, i.loaded, s.yards.divisionYard) ? null : 'NO_SUITABLE_CAR';
}
case 'newTrain.secondSection': {
@@ -2996,7 +2996,20 @@ function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
* this. A second copy stalled the game outright: the phase believed a car could be added while
* `check` rejected every option, so the Stage never completed.
*/
export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
export function acceptsCar(
tray: CrewTray,
carType: CarType,
/**
* Whether the car being offered is loaded, and what the Division Yard still holds.
*
* Both optional so that a caller asking the SHAPE question — "does this card take a car of this
* category at all?" — need not answer the loading question. `trainNeedingCars` asks the shape
* question of every car in the yard; `check` asks the full one about a specific car a player has
* named. Omitting them skips the loading rules rather than guessing at them.
*/
loaded?: boolean,
yard?: readonly RollingStock[],
): boolean {
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
if (!profile) return true;
@@ -3019,6 +3032,40 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
const types = profile.consist.freightTypes;
if (adding === 'freight' && types && !types.includes(carType)) return false;
if (loaded === undefined) return true;
/**
* X13 APPLESEED — "may drop MTs but not pick up anything", and its consist prints EMPTIES ONLY.
*
* `emptiesOnly` was declared on the card, RENDERED to the player as "(empties only)" by both
* `web/game.ts` and `sim/view.ts`, and enforced by nothing: the Appleseed could be made up with
* loaded cars while its own card said it could not. Found while building Gitea#13, which is the
* same rule pointing the other way, and fixed with it rather than left as the odd one out.
*
* A caboose is exempt. Every caboose in `ROLLING_STOCK_SUPPLY` is `loaded: true` — there is no
* such thing as an empty one — so applying this to the caboose would bar the Appleseed from the
* caboose its own consist calls for.
*/
if (profile.consist.emptiesOnly && loaded && adding !== 'caboose') return false;
/**
* MUST RUN LOADED (Gitea#13) — a preference order, not a flat requirement.
*
* "If not loaded, then empty, and if none available, run without." So an EMPTY is refused only
* while the yard can still supply a loaded car this train would accept; once it cannot, the empty
* becomes legal and the train may also simply depart short. Asked of the yard rather than
* remembered on the tray, because the yard is what the rule is about and it changes under the
* train as other consists are built.
*
* The caboose is exempt for the same reason as above.
*/
if (profile.rules.mustRunLoaded && !loaded && adding !== 'caboose' && yard) {
const loadedAvailable = yard.some(
(c) => c.loaded && cat(c.type) === adding && acceptsCar(tray, c.type),
);
if (loadedAvailable) return false;
}
return true;
}
@@ -3059,11 +3106,21 @@ export function isBeingMadeUp(tray: CrewTray): boolean {
export function trainNeedingCars(s: GameState): TrayId | null {
for (const [id, tray] of s.trays) {
if (!isBeingMadeUp(tray)) continue;
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
// the yard is potentially suitable unless the card narrows it. Ask the SAME predicate `check`
// uses: a separate copy of this test stalled the game, because the phase believed a car could
// be added while `check` rejected every option, so the Stage never ended.
if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type))) return id;
/**
* Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
* the yard is potentially suitable unless the card narrows it. Ask the SAME predicate `check`
* uses: a separate copy of this test stalled the game, because the phase believed a car could
* be added while `check` rejected every option, so the Stage never ended.
*
* ASKED PER CAR, WITH ITS LOADED STATE, since Gitea#13. The shape question alone is no longer
* the same question `check` answers: an `emptiesOnly` train looking at a yard of nothing but
* loaded cars, or a `mustRunLoaded` train offered only empties while loaded ones remain, would
* both be told a car was available and then refused every one of them — the very stall this
* comment was written about.
*/
if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type, c.loaded, s.yards.divisionYard))) {
return id;
}
}
return null;
}
+16 -3
View File
@@ -434,6 +434,19 @@ export type TrainRules = {
/** X17 Campaign, X18 Circus: a scheduled stop that does something. */
stopEarnsPoint?: boolean;
stopThenExpedite?: boolean;
/**
* MUST RUN LOADED (Gitea#13) — X17 Campaign, X18 Circus, X19 Military.
*
* "I've redefined some of the extra trains that they have to run full boxcars (not just any crazy
* stuff) — military trains, circus trains, etc. If not loaded, then empty, and if none available,
* run without." So it is a PREFERENCE ORDER enforced at make-up, not a flat requirement: a loaded
* car of an acceptable type must be taken while one is in the Division Yard; only once none is
* left may an empty be taken; and a train may still depart short (§8.2 already allows fewer cars
* than the card lists).
*
* Distinct from `ConsistSpec.emptiesOnly`, which is the opposite rule for X13 Appleseed.
*/
mustRunLoaded?: boolean;
/**
* `copiesNextScheduled` was here and is DELETED. No train card ever carried it: a Second Section
* is a Maneuver card played on a train that is due out, and it has its own intent
@@ -492,9 +505,9 @@ export const EXTRA_TRAINS: readonly TrainProfile[] = [
{ number: 14, isExtra: true, name: 'Fruit Growers Express', speed: 'fast', direction: 'playerChoice', consist: { freight: 2, coach: 0, caboose: 1, freightTypes: ['reefer'] }, rules: { expedite: true, note: 'Reefers only. May pick up one extra loaded reefer.' } },
{ number: 15, isExtra: true, name: 'Yard Xfer', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 0, caboose: 1 }, rules: {} },
{ number: 16, isExtra: true, name: 'Light Engine Move', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 0, caboose: 0 }, rules: { noSwitching: true, note: 'No cars at all.' } },
{ number: 17, isExtra: true, name: 'Campaign Train', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 1, caboose: 0 }, rules: { noSwitching: true, stopThenExpedite: true, note: 'One turn at station (speeches) then expedite.' } },
{ number: 18, isExtra: true, name: 'Circus Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 1 }, rules: { noSwitching: true, stopEarnsPoint: true, note: 'One turn stopped on any track (circus set-up) earns 1 point.' } },
{ number: 19, isExtra: true, name: 'Military Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 1, coach: 2, caboose: 0 }, rules: { noSwitching: true, noPassengerWork: true, expedite: true } },
{ number: 17, isExtra: true, name: 'Campaign Train', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 1, caboose: 0 }, rules: { noSwitching: true, stopThenExpedite: true, stopEarnsPoint: true, mustRunLoaded: true, note: 'One turn at station (speeches) then expedite. Earns a point per Office Area if the candidate is aboard.' } },
{ number: 18, isExtra: true, name: 'Circus Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 1 }, rules: { noSwitching: true, stopEarnsPoint: true, mustRunLoaded: true, note: 'One turn stopped in an Office Area (circus set-up) earns 1 point, once per Area, if fully loaded.' } },
{ number: 19, isExtra: true, name: 'Military Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 1, coach: 2, caboose: 0 }, rules: { noSwitching: true, noPassengerWork: true, expedite: true, mustRunLoaded: true, note: 'Troops and materiel: runs loaded where the yard can supply it.' } },
{ number: 20, isExtra: true, name: "Director's private car", speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 0 }, rules: { noPassengerWork: true } },
{ number: 21, isExtra: true, name: 'Freight Extra', speed: 'slow', direction: 'playerChoice', consist: { freight: 3, coach: 0, caboose: 1 }, rules: {} },
{ number: 22, isExtra: true, name: 'Pee-Dee', speed: 'slow', direction: 'playerChoice', consist: { freight: 0, coach: 0, caboose: 1 }, rules: { pickUpEmptiesOnly: true, note: 'Per-diem train. May only pick up MTs.' } },
+13 -6
View File
@@ -445,20 +445,27 @@ export type CrewTray = {
position: NodeRef;
movesUsed: number;
/**
* X18 Circus Train — "one turn stopped on any track (circus set-up) earns 1 point", claimed once.
* X18 Circus / X17 Campaign — Office Areas this train has already been paid for setting up in
* (Gitea#13).
*
* Recorded on the tray rather than the player because it is the TRAIN that sets up, and an Extra
* runs once and is gone; there is no second visit to claim it on.
* "Once per stop in an office area. In a multiplayer game, each player could score if the circus
* stops in their area" (Jesse, 2026-08-29). So the claim is per SEAT, not per train: a Circus
* touring three districts is paid three times, and one that parks in the same district for six
* Stages is paid once.
*
* Recorded on the tray, which also gives the other half of Jesse's ruling for free — "if the
* circus train gets recycled and played a second time as a second extra, then it could again
* score points later too". A train is made up onto a FRESH tray object every time, so a re-played
* Extra starts with an empty list and no reset code is needed.
*/
stopPointClaimed?: boolean;
stopPointSeats?: SeatIndex[];
/**
* X17 Campaign Train — "one turn at station (speeches) then expedite".
*
* It makes its speech at the first Office it reaches: that arrival is an ordinary stop, and from
* then on the train is expedited — it may be switched normally, but it faults (Q3) if it is left
* off the Office square when a Mainline Phase begins. Recorded on the tray for the same reason as
* `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs once, so there is no later
* visit to hang it on.
* `stopPointSeats` — it is the TRAIN that stops, and a re-played Extra gets a fresh tray.
*/
speechMade?: boolean;
};