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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5
View File
@@ -1389,6 +1389,11 @@
}, },
{ {
"type": "loadUnload.end" "type": "loadUnload.end"
},
{
"type": "game.extend",
"player": 0,
"agree": false
} }
] ]
} }
+51 -14
View File
@@ -50,6 +50,31 @@ export type AdvanceResult = {
const NIGHT_STAGES = new Set([1, 2, 3, 11, 12]); 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 { function movesForStage(s: GameState): number {
return s.config.optionalRules.reducedVisibility && NIGHT_STAGES.has(s.clock.stage) return s.config.optionalRules.reducedVisibility && NIGHT_STAGES.has(s.clock.stage)
? MOVES_PER_LOCAL_OPS_NIGHT ? MOVES_PER_LOCAL_OPS_NIGHT
@@ -420,33 +445,45 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
const where = tray.position; const where = tray.position;
const moved = moveTrain(s, id, tray, events); 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 * 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 * "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 * 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. * 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 = const stillThere =
tray.position.at === where.at && tray.position.at === where.at &&
(tray.position.at !== 'mainline' || where.at !== 'mainline' || tray.position.index === where.index) && (tray.position.at !== 'mainline' || where.at !== 'mainline' || tray.position.index === where.index) &&
(tray.position.at !== 'grid' || (tray.position.at !== 'grid' ||
where.at !== 'grid' || where.at !== 'grid' ||
(tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col)); (tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col));
if (stillThere) { // Bound as one value so the grid case narrows: `tray.position` is a union, and testing a
tray.stopPointClaimed = true; // `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. // 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 owner = playerAtSeat(s, at.seat);
const label = const label = `(${at.coord.col},${at.coord.row})`;
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`;
events.push({ type: 'trainStoodStill', trainNumber: tray.trainNumber ?? 0, where: label }); events.push({ type: 'trainStoodStill', trainNumber: tray.trainNumber ?? 0, where: label });
const p = s.players[owner]; const p = s.players[owner];
if (p) { if (p) {
@@ -456,7 +493,7 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
player: owner, player: owner,
delta: 1, delta: 1,
total: p.revenue, 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 // 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 // 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. // 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': { 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 * 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. * `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); const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
if (!profile) return true; if (!profile) return true;
@@ -3019,6 +3032,40 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
const types = profile.consist.freightTypes; const types = profile.consist.freightTypes;
if (adding === 'freight' && types && !types.includes(carType)) return false; 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; return true;
} }
@@ -3059,11 +3106,21 @@ export function isBeingMadeUp(tray: CrewTray): boolean {
export function trainNeedingCars(s: GameState): TrayId | null { export function trainNeedingCars(s: GameState): TrayId | null {
for (const [id, tray] of s.trays) { for (const [id, tray] of s.trays) {
if (!isBeingMadeUp(tray)) continue; 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` * Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
// uses: a separate copy of this test stalled the game, because the phase believed a car could * the yard is potentially suitable unless the card narrows it. Ask the SAME predicate `check`
// be added while `check` rejected every option, so the Stage never ended. * uses: a separate copy of this test stalled the game, because the phase believed a car could
if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type))) return id; * 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; return null;
} }
+16 -3
View File
@@ -434,6 +434,19 @@ export type TrainRules = {
/** X17 Campaign, X18 Circus: a scheduled stop that does something. */ /** X17 Campaign, X18 Circus: a scheduled stop that does something. */
stopEarnsPoint?: boolean; stopEarnsPoint?: boolean;
stopThenExpedite?: 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 * `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 * 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: 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: 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: 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: 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, note: 'One turn stopped on any track (circus set-up) earns 1 point.' } }, { 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 } }, { 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: 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: 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.' } }, { 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; position: NodeRef;
movesUsed: number; 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 * "Once per stop in an office area. In a multiplayer game, each player could score if the circus
* runs once and is gone; there is no second visit to claim it on. * 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". * 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 * 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 * 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 * 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 * `stopPointSeats` — it is the TRAIN that stops, and a re-played Extra gets a fresh tray.
* visit to hang it on.
*/ */
speechMade?: boolean; speechMade?: boolean;
}; };
+134 -21
View File
@@ -749,43 +749,156 @@ describe('MILESTONE: a full solitaire game runs headless', () => {
}); });
}); });
describe('X18 Circus Train — a point for standing still', () => { describe('X18 Circus / X17 Campaign — a point for setting up (Gitea#13)', () => {
it('pays once for a Stage spent stopped, and never again', () => { /**
/** * REPORTED originally: "Circus train TX18 was stopped on a siding for a full Stage and I did not
* REPORTED: "Circus train TX18 was stopped on a siding for a full Stage and I did not get my * get my Revenue point." It never could: `stopEarnsPoint` was declared on the profile and read
* Revenue point." It never could: `stopEarnsPoint` was declared on the profile and read * NOWHERE, along with eight other special-train rules.
* NOWHERE, along with eight other special-train rules. The one card in the deck that pays for *
* standing still paid nothing. * REDEFINED by Gitea#13 (Jesse, 2026-08-29), and these tests carry the three parts of that
*/ * ruling: the point is paid ONCE PER OFFICE AREA rather than once per game, only when the train
const s = game(); * is FULLY LOADED, and only in an Office Area at all.
*/
const circusAt = (s: GameState, seat: number, coord: { row: number; col: number }, consist: unknown[]) => {
s.clock.phase = 'mainline'; s.clock.phase = 'mainline';
s.trays.set('circus', { s.trays.set('circus', {
id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0, id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0,
consist: [], direction: 'east', consist, direction: 'east',
position: { at: 'grid', seat: 0, coord: { row: -1, col: 0 } }, position: { at: 'grid', seat, coord },
movesUsed: 0, movesUsed: 0,
} as never); } as never);
// A card under it, so the crew is somewhere real rather than off the grid. // A card under it, so the crew is somewhere real rather than off the grid.
areaOf(s, 0).grid.set('-1,0', { areaOf(s, seat as never).grid.set(`${coord.row},${coord.col}`, {
geometry: { kind: 'track', geometry: 'straight' }, geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [], baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never); } as never);
};
const loaded = [
{ type: 'boxcar', loaded: true },
{ type: 'boxcar', loaded: true },
{ type: 'coach', loaded: true },
{ type: 'caboose', loaded: true },
];
const runPhase = (s: GameState) => {
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
return pump(s);
};
it('pays a fully loaded Circus for a Stage spent set up', () => {
const s = game();
circusAt(s, 0, { row: -1, col: 0 }, loaded);
const before = s.players[0]!.revenue; const before = s.players[0]!.revenue;
const first = pump(s);
assert.ok( assert.ok(
first.some((e) => e.type === 'trainStoodStill' && e.trainNumber === 18), pump(s).some((e) => e.type === 'trainStoodStill' && e.trainNumber === 18),
'the Circus Train stood still for a Stage and earned nothing', 'the Circus Train stood still for a Stage and earned nothing',
); );
assert.equal(s.players[0]!.revenue, before + 1, 'the point was not paid'); assert.equal(s.players[0]!.revenue, before + 1, 'the point was not paid');
});
// "One turn stopped" — once. A train that goes on standing there does not keep earning. it('pays once per Office Area, however long it parks there', () => {
const paidAgain = () => { // "Once per stop in an office area" — a train that goes on standing in the same district does
s.clock.phase = 'mainline'; // not keep earning. This is the half that was already true, for a different reason.
s.movedThisPhase = new Set(); const s = game();
return pump(s).some((e) => e.type === 'trainStoodStill'); circusAt(s, 0, { row: -1, col: 0 }, loaded);
}; pump(s);
assert.ok(!paidAgain(), 'the Circus Train collected a second time for the same set-up'); assert.ok(!runPhase(s).some((e) => e.type === 'trainStoodStill'),
'the Circus collected twice for the same set-up');
assert.ok(!runPhase(s).some((e) => e.type === 'trainStoodStill'),
'the Circus collected a third time for the same set-up');
});
it('pays AGAIN in a different district — each player can be visited', () => {
/**
* The half that is new. "In a multiplayer game, each player could score if the circus stops in
* their area" — so the claim is per seat, and a touring Circus is paid by each district it sets
* up in. Before Gitea#13 this paid once per GAME and the second district got nothing.
*/
const s = createGame({
id: 'g', seed: 5, config: baseConfig({ mode: 'competitive' }), playerNames: ['A', 'B'],
});
circusAt(s, 0, { row: -1, col: 0 }, loaded);
pump(s);
const paidFirst = s.players.map((p) => p.revenue);
// The same train, moved into the other player's district.
const tray = s.trays.get('circus')!;
areaOf(s, 1 as never).grid.set('-1,0', {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never);
tray.position = { at: 'grid', seat: 1, coord: { row: -1, col: 0 } } as never;
assert.ok(runPhase(s).some((e) => e.type === 'trainStoodStill'),
'the Circus set up in a second district and earned nothing');
const owner = s.seating[1]!;
assert.equal(
s.players[owner]!.revenue,
paidFirst[owner]! + 1,
'the point did not go to whoever sits in the district it stopped in',
);
});
it('pays nothing when the cars are empty — "not much of a circus"', () => {
const s = game();
circusAt(s, 0, { row: -1, col: 0 }, [
{ type: 'boxcar', loaded: false },
{ type: 'coach', loaded: true },
{ type: 'caboose', loaded: true },
]);
const before = s.players[0]!.revenue;
assert.ok(!pump(s).some((e) => e.type === 'trainStoodStill'),
'an empty car aboard still collected the set-up point');
assert.equal(s.players[0]!.revenue, before, 'Revenue moved for a train that was not full');
});
it('pays nothing to a train carrying nothing at all', () => {
// `every` on an empty list is vacuously true, so the emptiest train of the lot is exactly the
// one a careless test would pay.
const s = game();
circusAt(s, 0, { row: -1, col: 0 }, []);
assert.ok(!pump(s).some((e) => e.type === 'trainStoodStill'),
'a Circus carrying nothing was paid for setting up');
});
it('pays nothing for standing out on the Mainline', () => {
/**
* It used to, and it misattributed the point: `playerAtSeat` needs a seat, there is none off
* the grid, and the fallback handed it to PLAYER 0 wherever the train was standing. Jesse's
* ruling scopes the rule to Office Areas, which removes the bug rather than patching it.
*/
const s = game();
s.clock.phase = 'mainline';
const index = s.division.nodes.findIndex((n) => n.kind === 'mainline');
s.trays.set('circus', {
id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0,
consist: loaded, direction: 'east',
position: { at: 'mainline', index },
movesUsed: 0,
} as never);
const before = s.players[0]!.revenue;
pump(s);
assert.equal(s.players[0]!.revenue, before, 'a Mainline set-up paid a point');
});
it('pays the Campaign Train only when its candidate is aboard', () => {
// X17 carries one coach and no freight, so "fully loaded" is exactly "the coach is occupied".
// It earned nothing at all before Gitea#13 — it had `stopThenExpedite` and no scoring rule.
const occupied = game();
circusAt(occupied, 0, { row: -1, col: 0 }, [{ type: 'coach', loaded: true }]);
occupied.trays.get('circus')!.trainNumber = 17;
const beforeOccupied = occupied.players[0]!.revenue;
pump(occupied);
assert.equal(occupied.players[0]!.revenue, beforeOccupied + 1, 'a full Campaign Train earned nothing');
const empty = game();
circusAt(empty, 0, { row: -1, col: 0 }, [{ type: 'coach', loaded: false }]);
empty.trays.get('circus')!.trainNumber = 17;
const beforeEmpty = empty.players[0]!.revenue;
pump(empty);
assert.equal(empty.players[0]!.revenue, beforeEmpty, 'an empty Campaign Train was paid for its speech');
}); });
}); });
+94
View File
@@ -770,6 +770,100 @@ describe('a turnout may be laid on top of a card already down', () => {
}); });
}); });
describe('extras that must run loaded, and one that must not (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."
*
* A PREFERENCE ORDER, so every test here is about what the DIVISION YARD still holds. The rule
* has nothing to say about a train once it is running; it decides which car may be taken next.
*/
const madeUp = (trainNumber: number) => {
const s = game();
s.clock.phase = 'newTrain';
s.trays.set('t', {
id: 't', trainNumber, trainIsExtra: true, engineAt: 0,
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
});
return s;
};
const place = (carType: string, loaded: boolean) =>
({ type: 'newTrain.placeCar', trayId: 't', carType, loaded }) as never;
/** Leaves the Division Yard holding exactly the cars described. */
const stockYard = (s: ReturnType<typeof madeUp>, cars: { type: string; loaded: boolean }[]) => {
s.yards.divisionYard.length = 0;
s.yards.divisionYard.push(...(cars as never[]));
};
it('refuses an empty while the yard can still supply a loaded one (X18 Circus)', () => {
const s = madeUp(18);
stockYard(s, [{ type: 'boxcar', loaded: true }, { type: 'boxcar', loaded: false }]);
assert.equal(check(s, 0, place('boxcar', false)), 'NO_SUITABLE_CAR',
'an empty was accepted while a loaded boxcar was still in the yard');
assert.equal(check(s, 0, place('boxcar', true)), null, 'the loaded boxcar was refused');
});
it('accepts an empty once the yard has no loaded car of that kind left', () => {
// "If not loaded, then empty." The rule releases as soon as the yard cannot supply.
const s = madeUp(18);
stockYard(s, [{ type: 'boxcar', loaded: false }]);
assert.equal(check(s, 0, place('boxcar', false)), null,
'an empty was refused when the yard held no loaded car at all');
});
it('does not let a loaded car of the WRONG category unlock the rule', () => {
// A loaded coach is no reason to refuse an empty boxcar: the preference is per category, since
// that is the slot the car is competing for.
const s = madeUp(18);
stockYard(s, [{ type: 'coach', loaded: true }, { type: 'boxcar', loaded: false }]);
assert.equal(check(s, 0, place('boxcar', false)), null,
'a loaded coach blocked an empty boxcar');
});
it('exempts the caboose, which is never empty in the supply', () => {
const s = madeUp(18);
stockYard(s, [{ type: 'caboose', loaded: true }, { type: 'boxcar', loaded: true }]);
assert.equal(check(s, 0, place('caboose', true)), null, 'the caboose its card calls for was refused');
});
it('applies to the Military train too', () => {
const s = madeUp(19);
stockYard(s, [{ type: 'coach', loaded: true }, { type: 'coach', loaded: false }]);
assert.equal(check(s, 0, place('coach', false)), 'NO_SUITABLE_CAR',
'the Military train took an empty coach over a loaded one');
});
it('leaves trains without the rule alone', () => {
// X21 Freight Extra has no loading rule: an empty is as good as a loaded one.
const s = madeUp(21);
stockYard(s, [{ type: 'boxcar', loaded: true }, { type: 'boxcar', loaded: false }]);
assert.equal(check(s, 0, place('boxcar', false)), null,
'a train with no loading rule was made to prefer loaded cars');
});
it('REGRESSION: X13 Appleseed is empties-only, and now the rules say so too', () => {
/**
* `ConsistSpec.emptiesOnly` was declared on the card, RENDERED to the player as "(empties only)"
* by `web/game.ts` and `sim/view.ts`, and enforced by NOTHING — `acceptsCar` never read it. So
* 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.
*/
const s = madeUp(13);
stockYard(s, [{ type: 'boxcar', loaded: true }, { type: 'boxcar', loaded: false }]);
assert.equal(check(s, 0, place('boxcar', true)), 'NO_SUITABLE_CAR',
'the Appleseed took a loaded car despite printing "empties only"');
assert.equal(check(s, 0, place('boxcar', false)), null, 'the Appleseed refused an empty');
});
it('still lets the Appleseed take the caboose its consist calls for', () => {
// Every caboose in ROLLING_STOCK_SUPPLY is minted loaded, so an unexempted empties-only rule
// would bar the one car the card explicitly lists.
const s = madeUp(13);
stockYard(s, [{ type: 'caboose', loaded: true }]);
assert.equal(check(s, 0, place('caboose', true)), null, 'the empties-only rule ate the caboose');
});
});
describe("a train is made up to its card's consist (§8.2)", () => { describe("a train is made up to its card's consist (§8.2)", () => {
it('takes a caboose when the card calls for one, and refuses a fourth freight car', () => { it('takes a caboose when the card calls for one, and refuses a fourth freight car', () => {
// Train 9 "Heavy Freight" is freight 3 + caboose 1. It was being made up with FOUR hoppers and // Train 9 "Heavy Freight" is freight 3 + caboose 1. It was being made up with FOUR hoppers and