v0.4.7 — the switching game: track order, the cut on your own card, and four rules
Eight play reports and one design that had been written up and not built. The
through-line is switching: what a card can hold, which end of a train a cut comes
off, which way a train meets cars standing on the line, and what the board and the
log say about all of it.
TRACK ORDER FOR STANDING CARS, AND THE CUT ON YOUR OWN CARD
Two reports turned out to be one root cause. `TrackCard.standing` claimed "in track
order (§A.3)" and had no defined orientation at all, while `CrewTray.consist` does
(nose first, relative to facing) — so every transfer between them was a conversion
nothing performed. §A.3 says what it should be: cars occupy the track "in the same
order they originally held, left-to-right". Left-to-right is west-to-east, and that
is now the defined orientation of `standing` and of an industry track through
`carsOn`. It is the board's orientation, not the train's, so it does not change when
a different train touches the card.
- Setting out is batch-invariant. Four cars at once, four singles and two pairs
parked three different orders, one of them physically impossible. Successive
cuts off the same end stack up towards the engine, so the insertion point is the
train's own place in the row.
- Approaching a cut from either end now mirrors. `couples` is built nearest-first
along the direction of travel and reverses onto the nose, so the farthest car met
ends up nose-most — which is what makes a run-around worth its Move.
- A train no longer drives through its own cut. The walk began at the neighbour of
the start square and never read the start card, so a crew could set cars out and
pull straight away from them. Coupling is mandatory (§A.4) and your own square is
no exception; the cut counts against the four-car limit. Setting out off the end
you are not leaving by still works.
`CrewTray.standingWest` records where a train stands among the cars on its card — a
train may set out off both ends on one square, so which side a cut is on is not
recoverable from the array alone.
On the board, the cut is drawn split at the train — west cars left, east cars right,
engine in the gap — and each car's tooltip says whether it stands ahead of or behind
the engine. The history says which end a cut came off, and a move's button separates
"takes your own boxcar back off this card" from cars found standing on the line.
Decided: taking your own cut back on the square you are standing on is UNDOING the
drop. It is exempt from trains 3/4's per-location freight budget, X13's "drop but not
pick up" and X22's "empties only", and it refunds the budget the drop spent.
Otherwise a legal-looking drop becomes silently one-way.
Measured, 200 paired seeds, developer bot: -0.55 revenue (t = -3.63), freight revenue
1.11 -> 0.56. That cost is the bot's, not the rule's — its trains run engine-first,
so at a stub industry it sets a car out between itself and the only way out, and the
correct play is §A.5's facing-point move, which is the cross-turn planning TODO.md
already records as out of reach of any bot. Filtering self-recoupling moves out of its
options took recoupling from 625 of 1,029 set-outs to 101 of 677, and all 101 that
remain are that case. Read the number as a bot measurement, not a balance one.
THE SUPERINTENDENT'S RULING NAMES THE TRAINS IT IS ABOUT
Reported: the Superintendent could not tell which train he was clearing. The heading
asks the question now — "may Train 6 follow Train 4 onto the same Mainline card?" —
and the trains moved to the FRONT of each button, because the button splits its label
at the first em-dash and showed only the head.
AN INDUSTRY TRACK HOLDS FOUR CARS, LIKE EVERY OTHER CARD
Reported at undo 188: "we wanted to drop two cars, but were only allowed to drop one."
An industry track was built as long as its box count, so a one-box industry had room
for one car. Box count is how much WORK an industry can hold, not how much RAIL it
has. Ordinary track was the other exception, unbounded; both are gone and every card
holds four.
THE FREIGHT AGENT MAY STAGE A LOAD BEFORE THE CAR IS THERE
§6.3 asks nothing of the industry track — the empty car belongs to §9.3's Load the
car, which is the Laborer's action. The gate now lives only there, so cargo can wait
on the dock while the car to ship it in is still being switched in. Nothing can jam:
a load in a green box is waiting, not stuck.
THE TRUCK DOCK UNLOADS, AND BRINGS NOBODY
+1 inbound, no Laborer. It printed +1 outbound and +1 Laborer, which made it a
longer-host-list copy of Forklifts. Beside Packing Sheds it now does nothing at all,
and the hand tooltip says so before it is played.
Also in this release, from the days before: Mainline card tooltips computed from the
crossing rule; an Extra starts from the Division Point its number sends it to; a
modifier's suppressed grant comes back when a Whistle Post is upgraded; the Oil
Refinery and the Grocer's Warehouse ship as well as receive, per the card reference;
and the dormant defences name the attack they answer. `.claude/` is now gitignored —
it holds Claude Code's worktrees, i.e. a second checkout of this repository.
570 tests, typecheck clean. The three published replays were re-recorded twice —
legality changed, so bot play changed. Full detail in CHANGELOG.md.
This commit is contained in:
+276
-58
@@ -26,6 +26,8 @@ import {
|
||||
mainlineModifierRule,
|
||||
mainlineProfile,
|
||||
houseRules,
|
||||
runDirection,
|
||||
startingDivisionPoint,
|
||||
modifierProfile,
|
||||
nextOfficeTier,
|
||||
officeProfile,
|
||||
@@ -51,7 +53,19 @@ import type {
|
||||
TurnoutOrientation,
|
||||
} from './state.ts';
|
||||
import { createRng } from './rng.ts';
|
||||
import { carsOn, coordKey, isOperationalRail, playerAtSeat, seatOf, spaceOn, turnOf } from './state.ts';
|
||||
import {
|
||||
carsOn,
|
||||
coordKey,
|
||||
cutTowards,
|
||||
isOperationalRail,
|
||||
playerAtSeat,
|
||||
railFacingOf,
|
||||
seatOf,
|
||||
spaceOn,
|
||||
standingSides,
|
||||
trackOrder,
|
||||
turnOf,
|
||||
} from './state.ts';
|
||||
import type { MoveBlock, Occupancy, Port } from './track.ts';
|
||||
import {
|
||||
canDropCarsAt,
|
||||
@@ -330,35 +344,25 @@ function workTrack(f: Facility): [Load | null, Load | null, Load | null] {
|
||||
return f.menAtWork;
|
||||
}
|
||||
|
||||
/**
|
||||
* HOW MANY EMPTY CARS OF THIS TYPE ARE SPOTTED AND NOT ALREADY PROMISED TO A LOAD.
|
||||
*
|
||||
* §9.3 — a load has to have somewhere to go. You cannot start the MEN | AT | WORK moves until an
|
||||
* empty car of the right type is standing on the industry's track, "otherwise you are just dropping
|
||||
* cargo onto the tracks — pointless waste". The final swap needs a car of the LOAD's type, so a
|
||||
* hopper load cannot be swapped onto a tank: strict matching, not "any empty car".
|
||||
*
|
||||
* Counted rather than merely present, because green boxes and industry tracks both grow past one
|
||||
* slot with Modifiers (measured: capacity > 1 on 24.5% of freight facilities and a track longer than
|
||||
* one on 41%). Two loads staged against one spotted car would put the second one on the sign with
|
||||
* nowhere to land, which is the jam this rule exists to prevent — just one step later.
|
||||
*
|
||||
* Loads already in the green box and already walking the sign both count as claims on a car.
|
||||
*/
|
||||
export function unclaimedEmpties(f: Facility, type: CarType): number {
|
||||
const spotted = f.industryTrack.cars.filter((c) => !c.loaded && c.type === type).length;
|
||||
const staged = f.outboundBox.filter((c) => c.type === type).length;
|
||||
const working = (f.menAtWork ?? []).filter((l) => l?.dir === 'out' && l.type === type).length;
|
||||
return spotted - staged - working;
|
||||
}
|
||||
|
||||
/**
|
||||
* The green-box load that may start down the sign, or null if none may.
|
||||
*
|
||||
* §9.3 — a load has to have somewhere to go before the Laborers touch it. It may not start the
|
||||
* MEN | AT | WORK moves until an empty car of the right type is standing on the industry's track,
|
||||
* "otherwise you are just dropping cargo onto the tracks — pointless waste". The final swap needs a
|
||||
* car of the LOAD's type, so a hopper load cannot be swapped onto a tank: strict matching, not "any
|
||||
* empty car". THIS IS THE ONLY PLACE THAT RULE APPLIES — stocking the green box is free of it
|
||||
* (§6.3), so cargo may wait on the dock for a car that has not been switched in yet.
|
||||
*
|
||||
* Not simply `outboundBox[0]`. A Power Plant takes hoppers AND tanks, so a green box holding a
|
||||
* hopper load and a tank load against one spotted empty tank must start the TANK — taking the first
|
||||
* entry regardless would report the whole facility as blocked while a perfectly legal load sat
|
||||
* beside it.
|
||||
*
|
||||
* Cars are COUNTED against claims rather than merely looked for, because green boxes and industry
|
||||
* tracks both grow past one slot with Modifiers (measured: capacity > 1 on 24.5% of freight
|
||||
* facilities and a track longer than one on 41%). Two loads walking the sign toward one spotted car
|
||||
* would strand the second on WORK, which is the jam this rule exists to prevent.
|
||||
*/
|
||||
export function startableLoad(f: Facility): number | null {
|
||||
const claims = new Map<CarType, number>();
|
||||
@@ -513,6 +517,28 @@ function spendFreightBudget(
|
||||
turn.freightWorked[key] = (turn.freightWorked[key] ?? 0) + n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give back what a set-out spent, when the train picks its own cut straight back up.
|
||||
*
|
||||
* The mirror of `spendFreightBudget` and deliberately its own function: "undo the drop" is a rule,
|
||||
* not an arithmetic detail, and it applies on the square the cars were LEFT on rather than the one
|
||||
* the train ends up at.
|
||||
*/
|
||||
function refundFreightBudget(
|
||||
s: GameState,
|
||||
player: PlayerIndex,
|
||||
tray: CrewTray,
|
||||
at: GridCoord,
|
||||
stock: readonly RollingStock[],
|
||||
): void {
|
||||
if (!rulesOf(tray).oneFreightPerLocation) return;
|
||||
const n = stock.filter(isFreight).length;
|
||||
if (n === 0) return;
|
||||
const turn = turnOf(s, player);
|
||||
const key = freightWorkedKey(tray.id, at);
|
||||
turn.freightWorked[key] = Math.max(0, (turn.freightWorked[key] ?? 0) - n);
|
||||
}
|
||||
|
||||
/** Trains that may not be worked by Porters at all (§7): the Military train and the Director's car. */
|
||||
function refusesPassengers(tray: CrewTray): boolean {
|
||||
return rulesOf(tray).noPassengerWork === true;
|
||||
@@ -613,10 +639,21 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
* on the move or it cannot bite at all.
|
||||
*/
|
||||
const rules = rulesOf(tray);
|
||||
if (dest.couples.length > 0) {
|
||||
/**
|
||||
* THE TRAIN'S OWN CUT IS NOT A PICK-UP. Taking back the cars you just set out on the square you
|
||||
* are standing on undoes the drop; it is not fresh work, and none of the three restrictions
|
||||
* that gate picking cars up should bite on it.
|
||||
*
|
||||
* Left in and the rule reads absurdly: X13 prints "may drop but not pick up", so a legal drop
|
||||
* off the nose would leave the train forbidden to pull forward past its own cars for the rest
|
||||
* of the turn — a one-way move nothing warned about. Only the cars that were ALREADY there when
|
||||
* the crew arrived are a pick-up, so the own cut is subtracted before the tests run.
|
||||
*/
|
||||
const fresh = dest.couples.slice(ownCutFor(s, player, i.trayId, i.reverse).length);
|
||||
if (fresh.length > 0) {
|
||||
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
|
||||
if (rules.pickUpEmptiesOnly && dest.couples.some((c) => c.loaded)) return 'EMPTIES_ONLY';
|
||||
const freight = dest.couples.filter(isFreight).length;
|
||||
if (rules.pickUpEmptiesOnly && fresh.some((c) => c.loaded)) return 'EMPTIES_ONLY';
|
||||
const freight = fresh.filter(isFreight).length;
|
||||
if (freight > 0 && !freightBudgetLeft(s, player, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE';
|
||||
}
|
||||
return null;
|
||||
@@ -820,14 +857,21 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
return 'NO_SUITABLE_CAR';
|
||||
}
|
||||
/**
|
||||
* §9.3 — FREIGHT NEEDS A CAR AT THE DOCK BEFORE THE CARGO IS FETCHED. Staging a load against
|
||||
* no car is fetching cargo you cannot ship.
|
||||
* STOCKING DOES NOT NEED A CAR SPOTTED — THE LABORERS DO.
|
||||
*
|
||||
* PASSENGERS ARE DELIBERATELY DIFFERENT and this is not an oversight: people can wait on the
|
||||
* platform for a train that has not arrived, so a Passenger Facility stocks freely. Freight
|
||||
* cannot — a crate on the ground is not a shipment.
|
||||
* §6.3 asks for nothing but a car in the Division Yard and room in the box: the Freight Agent
|
||||
* "select[s] one Rolling Stock from the Division Yard pile and place[s] it onto a Facility's
|
||||
* green Outbound box". The empty-car requirement belongs one step later, to §9.3's "Load the
|
||||
* car" — "*a load in the Green Loading Box AND an empty car of the required type on the
|
||||
* industry's track*" — which is the action that walks the load down MEN | AT | WORK.
|
||||
*
|
||||
* The engine used to hoist that requirement forward onto stocking, and it made the ordinary
|
||||
* play illegal: an agent may perfectly well have the cargo waiting on the dock while the car
|
||||
* to ship it in is still being switched in. Nothing jams as a result — a load sitting in a
|
||||
* green box is waiting, not stuck, and only a load on MEN | AT | WORK locks the industry
|
||||
* track. `laborer.startLoad` holds the real gate (`startableLoad`), so a load can be staged
|
||||
* early but still cannot start down the sign until its car is standing there.
|
||||
*/
|
||||
if (f.kind === 'freight' && unclaimedEmpties(f, i.carType) < 1) return 'NO_EMPTY_CAR_SPOTTED';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -859,6 +903,20 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
}
|
||||
|
||||
// -- New Train ------------------------------------------------------------
|
||||
case 'newTrain.startExtra': {
|
||||
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
|
||||
if (!s.pendingExtras.includes(i.trainNumber)) return 'NO_EXTRA_PENDING';
|
||||
if (s.freeTrays.length === 0) return 'NO_FREE_TRAY';
|
||||
if (i.atSeat === null) return null;
|
||||
// A Control Point is any Office above a Whistle Post (§8). A Whistle Post is not one, which is
|
||||
// the whole reason upgrading buys a place for an Extra to start.
|
||||
const area = s.officeAreas.get(i.atSeat);
|
||||
if (!area) return 'NO_SUCH_FACILITY';
|
||||
if (!officeProfile(area.tier).isControlPoint) return 'NOT_A_CONTROL_POINT';
|
||||
// It still has to fit: an Extra starting here takes an A/D track like any other arrival.
|
||||
return area.adOccupancy.length >= officeProfile(area.tier).adTracks ? 'NO_FREE_AD_TRACK' : null;
|
||||
}
|
||||
|
||||
case 'newTrain.placeCar': {
|
||||
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
@@ -934,9 +992,12 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (!f.menAtWork) return 'NO_SUCH_FACILITY';
|
||||
if (f.outboundBox.length === 0) return 'BOX_EMPTY';
|
||||
if (f.menAtWork[0] !== null) return 'BOX_FULL';
|
||||
// The car can be coupled away after the cargo was staged — running over an industry track
|
||||
// couples whatever stands on it, mandatorily (§A.4) — so the Freight Agent's check is not
|
||||
// enough on its own. Both gates are required.
|
||||
// §9.3's "Load the car" requires "*an empty car of the required type on the industry's
|
||||
// track*", and THIS is where that is enforced — stocking the green box (§6.3) is deliberately
|
||||
// free of it. Cargo may be staged against a car that is still being switched in; it simply
|
||||
// cannot leave the box until the car is standing there. The check also has to be made at this
|
||||
// moment rather than earlier because a car can be coupled away after the cargo was staged:
|
||||
// running over an industry track couples whatever stands on it, mandatorily (§A.4).
|
||||
return startableLoad(f) !== null ? null : 'NO_EMPTY_CAR_SPOTTED';
|
||||
}
|
||||
|
||||
@@ -1142,12 +1203,30 @@ function destinationsFor(
|
||||
occupancy: occupancyFor(s, player, trayId),
|
||||
consistSize: tray.consist.length,
|
||||
self: trayId,
|
||||
...(tray.standingWest === undefined ? {} : { standingWest: tray.standingWest }),
|
||||
},
|
||||
from,
|
||||
exit,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cars this crew would take back off its OWN card by pulling out this way.
|
||||
*
|
||||
* Exported because three places need the same answer and none of them should re-derive the exit port
|
||||
* to get it: the move check exempts these cars from a train's pick-up restrictions, the move label
|
||||
* names them separately from cars found on the line, and the walk counts them against the four-car
|
||||
* limit. Empty for a train with nothing standing beside it, which is almost every move.
|
||||
*/
|
||||
export function ownCutFor(s: GameState, player: PlayerIndex, trayId: TrayId, reverse: boolean): RollingStock[] {
|
||||
const tray = s.trays.get(trayId);
|
||||
if (!tray || tray.position.at !== 'grid') return [];
|
||||
const here = tray.position.coord;
|
||||
const facing = facingPort(s, trayId);
|
||||
const exit: Port = reverse ? reversePort(s, player, here, facing) : facing;
|
||||
return cutTowards(tray, carsOn(areaOf(s, player).grid.get(coordKey(here)) ?? emptyCard()), exit);
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE THIS CREW MAY GO, AND WHY IT MAY NOT GO ELSEWHERE.
|
||||
*
|
||||
@@ -1171,6 +1250,7 @@ export function movesFor(
|
||||
occupancy: occupancyFor(s, player, trayId),
|
||||
consistSize: tray.consist.length,
|
||||
self: trayId,
|
||||
...(tray.standingWest === undefined ? {} : { standingWest: tray.standingWest }),
|
||||
};
|
||||
const facing = facingPort(s, trayId);
|
||||
const forward = exploreMoves(ctx, from, facing);
|
||||
@@ -1204,6 +1284,11 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
const from = trayCoord(s, i.trayId)!;
|
||||
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
|
||||
const dest = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)!;
|
||||
const tray = s.trays.get(i.trayId)!;
|
||||
// The port the crew pulls out THROUGH — the same one `destinationsFor` explored from, so the
|
||||
// cut it recouples on the way out is the cut the walk counted.
|
||||
const facingNow = facingPort(s, i.trayId);
|
||||
const exitPort: Port = i.reverse ? reversePort(s, player, from, facingNow) : facingNow;
|
||||
// The crew leaves by the port opposite the one it entered through, which is what it will be
|
||||
// facing when it stops. Without this the facing stays 'e'/'w' forever and a crew that turns
|
||||
// onto a north-south spur can never move again.
|
||||
@@ -1241,14 +1326,28 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
},
|
||||
];
|
||||
if (dest.couples.length > 0) {
|
||||
// Name the cards the cars came from. The reducer used to clear every card in the district
|
||||
// instead, so one coupling anywhere deleted every standing car and every industry track in
|
||||
// the Office Area — loads worked over several Stages vanished when a crew picked up a single
|
||||
// boxcar somewhere else entirely.
|
||||
/**
|
||||
* Name the cards the cars came from. The reducer used to clear every card in the district
|
||||
* instead, so one coupling anywhere deleted every standing car and every industry track in
|
||||
* the Office Area — loads worked over several Stages vanished when a crew picked up a single
|
||||
* boxcar somewhere else entirely.
|
||||
*
|
||||
* `from` NOW BEGINS WITH THE SQUARE THE CREW LEFT. It could not before — the list was built
|
||||
* from `dest.path` plus the destination, neither of which can ever contain the start — so a
|
||||
* cut recoupled off your own card was added to the consist and left standing on the board at
|
||||
* the same time, one car becoming two. The cars that stay behind (a cut set out off the
|
||||
* OTHER end) ride along on `leaves`, because by the time this is reduced the tray has moved
|
||||
* and `standingWest` no longer describes this card.
|
||||
*/
|
||||
const grid = areaOf(s, player).grid;
|
||||
const startCut = cutTowards(tray, carsOn(grid.get(coordKey(from)) ?? emptyCard()), exitPort);
|
||||
const lifted = [
|
||||
...(startCut.length > 0 ? [from] : []),
|
||||
...dest.path.map((step) => step.coord),
|
||||
i.to,
|
||||
].filter((c) => carsOn(areaOf(s, player).grid.get(coordKey(c)) ?? emptyCard()).length > 0);
|
||||
].filter((c) => carsOn(grid.get(coordKey(c)) ?? emptyCard()).length > 0);
|
||||
const sides = standingSides(tray, carsOn(grid.get(coordKey(from)) ?? emptyCard()));
|
||||
const stayed = exitPort === 'e' ? sides.west : exitPort === 'w' ? sides.east : [];
|
||||
// §A.3 — "engines also have couplers on the front end, so a train can pick cars up onto
|
||||
// its nose". Running forward the engine meets cars head-on and takes them in front; backing
|
||||
// up, they couple behind. Which end they land on is the whole point of a run-around: it
|
||||
@@ -1260,6 +1359,8 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
stock: dest.couples,
|
||||
from: lifted,
|
||||
toNose: !i.reverse,
|
||||
...(startCut.length > 0 && stayed.length > 0 ? { leaves: { at: from, stock: stayed } } : {}),
|
||||
...(startCut.length > 0 ? { recoupled: { at: from, stock: startCut } } : {}),
|
||||
});
|
||||
}
|
||||
return events;
|
||||
@@ -1436,6 +1537,9 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
return [{ type: 'facilityUnjammed', player, at: i.at, from: i.from, stock }];
|
||||
}
|
||||
|
||||
case 'newTrain.startExtra':
|
||||
return [{ type: 'extraStarted', player, trainNumber: i.trainNumber, atSeat: i.atSeat }];
|
||||
|
||||
case 'newTrain.placeCar':
|
||||
return [
|
||||
{
|
||||
@@ -1580,6 +1684,11 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
// their turn. The event carries no player of its own.
|
||||
turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining;
|
||||
|
||||
// A MOVE ENDS ON AN EMPTY CARD, always: coupling is mandatory, so anything standing on the
|
||||
// destination has just been lifted into the tray. Whatever this crew had beside it belonged to
|
||||
// the square it left, so its place in that row is gone with it.
|
||||
tray.standingWest = 0;
|
||||
|
||||
// An A/D track is held only while the train is actually standing at the Office (§2.1).
|
||||
// Leaving it out of sync means the Office looks permanently full and every arrival collides.
|
||||
const area = areaAtSeat(s, seat);
|
||||
@@ -1593,12 +1702,25 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
case 'carsCoupled': {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
const area = areaAtSeat(s, tray.position.at === 'grid' ? tray.position.seat : 0);
|
||||
/**
|
||||
* `stock` ARRIVES NEAREST-FIRST — the order the engine MET the cars, accumulated along the
|
||||
* walk. A tray is ordered nose first, so only one of the two ends needs turning round.
|
||||
*
|
||||
* Behind the train, near-first is already right: the nearest car couples to the existing tail
|
||||
* and each one after it goes further back, which is the order the array is in.
|
||||
*
|
||||
* On the nose it is exactly backwards. The FARTHEST car ends up nose-most — the engine pushes
|
||||
* the first one it met ahead of it and picks up the next in front of that — so the cut reverses
|
||||
* on the way into the tray. Getting this wrong is invisible in a single move and shows up as a
|
||||
* run-around that changes nothing: approaching one parked cut from either end used to give the
|
||||
* identical consist, when the whole point is that the two mirror.
|
||||
*/
|
||||
if (e.toNose) {
|
||||
// Cars taken on the nose go AHEAD of the engine — "pushing them into the Facility" — so the
|
||||
// engine is no longer at the front and its index has to follow. It never did, so a crew that
|
||||
// shoved a cut anywhere kept a tray claiming the engine was still leading, and §8.2's
|
||||
// make-up rule had nothing truthful to check.
|
||||
tray.consist.unshift(...e.stock);
|
||||
tray.consist.unshift(...[...e.stock].reverse());
|
||||
tray.engineAt += e.stock.length;
|
||||
} else {
|
||||
tray.consist.push(...e.stock);
|
||||
@@ -1611,7 +1733,24 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
card.standing = [];
|
||||
if (card.facility) card.facility.industryTrack.cars = [];
|
||||
}
|
||||
spendFreightBudget(s, playerAtSeat(s, trayySeat(tray)), tray, e.at, e.stock);
|
||||
// The card the crew pulled OUT of may still be holding a cut set out off its other end, which
|
||||
// the sweep above has just emptied along with everything else. Put it back.
|
||||
if (e.leaves) {
|
||||
const card = area.grid.get(coordKey(e.leaves.at));
|
||||
if (card) carsOn(card).push(...e.leaves.stock.map((c) => ({ ...c })));
|
||||
}
|
||||
// A tray that has moved is standing on a card it has just emptied, so nothing of its own is
|
||||
// left beside it. Any cut it did leave behind is on the square it came FROM.
|
||||
tray.standingWest = 0;
|
||||
/**
|
||||
* Charge only the cars that were ALREADY standing where the crew ran — the own cut at the
|
||||
* front of `stock` is a drop being undone, so it is refunded on the square it was left on
|
||||
* instead of being charged again at the far end.
|
||||
*/
|
||||
const owner = playerAtSeat(s, trayySeat(tray));
|
||||
const taken = e.recoupled ? e.stock.slice(e.recoupled.stock.length) : e.stock;
|
||||
spendFreightBudget(s, owner, tray, e.at, taken);
|
||||
if (e.recoupled) refundFreightBudget(s, owner, tray, e.recoupled.at, e.recoupled.stock);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1640,8 +1779,35 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
}
|
||||
tray.engineAt = Math.min(tray.engineAt, tray.consist.length);
|
||||
const card = area.grid.get(coordKey(e.at));
|
||||
// On a Facility card the industry track is where cars stand (§9.3).
|
||||
if (card) carsOn(card).push(...e.stock);
|
||||
/**
|
||||
* WHERE THE CUT LANDS ON THE CARD — the fix for "when dropping all 4 cars, order was
|
||||
* reversed; it worked properly if we dropped cars individually".
|
||||
*
|
||||
* This was `carsOn(card).push(...stock)`, which is neither of the two things it needs to be.
|
||||
* The cut arrives in TRAY order (nose first) and the card is ordered WEST TO EAST, so it has
|
||||
* to be turned around for an east-facing train — `trackOrder`. And it has to go at the end of
|
||||
* the row the cut physically occupies, which is not always the end of the array:
|
||||
*
|
||||
* - a NOSE cut is set out ahead of the engine, on the `facing` side;
|
||||
* - a TAIL cut is set out behind it, on the other side.
|
||||
*
|
||||
* Successive cuts off the same end stack up TOWARDS the engine — the first car set out is left
|
||||
* furthest away, and each one after it is left in the gap between the train and the last —
|
||||
* so the insertion point is the train's own position in the row, `standingWest`, from either
|
||||
* side. That is what makes the result batch-invariant: four cars at once, four singles, or two
|
||||
* pairs all park in the same order, which is the reported bug and the regression test.
|
||||
*/
|
||||
if (card) {
|
||||
const cars = carsOn(card);
|
||||
const facing = railFacingOf(tray);
|
||||
const onWestSide = e.fromNose ? facing === 'w' : facing === 'e';
|
||||
const k = Math.max(0, Math.min(cars.length, tray.standingWest ?? 0));
|
||||
const cut = trackOrder(e.stock, facing);
|
||||
cars.splice(k, 0, ...cut);
|
||||
// The train has not moved, so cars set out on its WEST side push its index along the row;
|
||||
// cars set out to the east leave it where it was.
|
||||
tray.standingWest = onWestSide ? k + cut.length : k;
|
||||
}
|
||||
spendFreightBudget(s, playerAtSeat(s, trayySeat(tray)), tray, e.at, e.stock);
|
||||
break;
|
||||
}
|
||||
@@ -1770,7 +1936,32 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
};
|
||||
// Becoming a Passenger Facility at all is a state change, not a delta (Gap 8): a Whistle
|
||||
// Post has no passenger boxes to add to.
|
||||
const wasPassenger = f.allows.outbound || f.allows.inbound;
|
||||
f.allows = { outbound: to.isPassengerFacility, inbound: to.isPassengerFacility };
|
||||
|
||||
/**
|
||||
* A GRANT SUPPRESSED AT A WHISTLE POST COMES BACK WHEN THE OFFICE CAN USE IT.
|
||||
*
|
||||
* Reported from play: "Restaurant attached to a whistle stop, then upgrade to depot — depot
|
||||
* only shows one green / one red box. I expected two, because Restaurant increases outbound
|
||||
* by one." Exactly right, and here is why it happened: `hosts: ['office']` includes a Whistle
|
||||
* Post, which is NOT a Passenger Facility, so `usableGrant` dropped the +1 outbound at the
|
||||
* moment the card was played. The porter landed — porters have no direction gate — and the
|
||||
* slot was gone for good, because the upgrade only ever applied the difference between two
|
||||
* tiers and knew nothing about what had been discarded on the way.
|
||||
*
|
||||
* `TrackCard.modifiers` records which Modifiers served this facility, so what was dropped is
|
||||
* recoverable: when the Office becomes a Passenger Facility, every one of them is granted the
|
||||
* capacity it always printed. Keyed on the TRANSITION, so a Depot → Station upgrade does not
|
||||
* pay them a second time.
|
||||
*/
|
||||
if (!wasPassenger && to.isPassengerFacility) {
|
||||
for (const kind of officeCard.modifiers) {
|
||||
const m = modifierProfile(kind);
|
||||
f.capacity.outbound += m.addOut;
|
||||
f.capacity.inbound += m.addIn;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1858,6 +2049,32 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'extraStarted': {
|
||||
const trayId = s.freeTrays.pop()!;
|
||||
s.pendingExtras = s.pendingExtras.filter((n) => n !== e.trainNumber);
|
||||
const direction = runDirection(e.trainNumber);
|
||||
if (e.atSeat === null) {
|
||||
const side = startingDivisionPoint(e.trainNumber);
|
||||
s.trays.set(trayId, {
|
||||
id: trayId, trainNumber: e.trainNumber, trainIsExtra: true, engineAt: 0, consist: [],
|
||||
direction, position: { at: 'divisionPoint', side }, movesUsed: 0,
|
||||
});
|
||||
const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === side);
|
||||
if (dp?.kind === 'divisionPoint') dp.holding.push(trayId);
|
||||
} else {
|
||||
// Starting at a Control Point: it stands on the Office card and takes an A/D track, exactly
|
||||
// as though it had arrived there.
|
||||
const area = areaAtSeat(s, e.atSeat);
|
||||
s.trays.set(trayId, {
|
||||
id: trayId, trainNumber: e.trainNumber, trainIsExtra: true, engineAt: 0, consist: [],
|
||||
direction, facing: direction === 'west' ? 'w' : 'e', railFacing: direction === 'west' ? 'w' : 'e',
|
||||
position: { at: 'grid', seat: e.atSeat, coord: area.officeCoord }, movesUsed: 0,
|
||||
});
|
||||
area.adOccupancy.push(trayId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'passengersBoarded': {
|
||||
const f = facilityAt(s, e.player, e.at)!;
|
||||
const area = areaOf(s, e.player);
|
||||
@@ -2046,7 +2263,7 @@ function buildFreightFacility(kind: FreightKind): Facility {
|
||||
// industry-specific modifier cards, not printed large on the industry itself.
|
||||
capacity: { outbound: p.baseOut, inbound: p.baseIn },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { length: Math.max(1, p.baseOut + p.baseIn), cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: p.baseLoaders,
|
||||
porters: 0,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
@@ -2290,13 +2507,15 @@ function hasModifierInArea(area: OfficeArea, kind: ModifierKind): boolean {
|
||||
* the laborer landed because Laborers have no direction, and the slot did not because there was
|
||||
* nowhere for it to go. No modifier turns a receiver into a shipper, so the grant is dropped.
|
||||
*
|
||||
* It is not one card's quirk. The same trap catches **Truck Dock** and **Forklifts**, which also
|
||||
* print `addOut` and also list `grocersWarehouse` among their hosts, and **Waiting Area**,
|
||||
* **Restaurant** and **Hotel**, whose `hosts: ['office']` includes a Whistle Post — not a Passenger
|
||||
* Facility, so `allows.outbound` is false there too.
|
||||
* It is not one card's quirk, and it cuts both ways. **Waiting Area**, **Restaurant** and **Hotel**
|
||||
* print `addOut` for `hosts: ['office']`, which includes a Whistle Post — not a Passenger Facility,
|
||||
* so `allows.outbound` is false there too. **Truck Dock** is the mirror image: it prints `addIn` and
|
||||
* lists **Packing Sheds**, which only ships, so its one grant is dropped there and the card does
|
||||
* nothing at all. Which host you set it beside is the whole decision, and the hand tooltip says so
|
||||
* before it is played.
|
||||
*
|
||||
* The industry track grows by what was actually applied, not by what was printed: a slot that does
|
||||
* not exist must not lengthen the siding that would have served it.
|
||||
* What is applied is what the host can actually use, not what the card printed. It affects the box
|
||||
* count only — see `applyModifier` on why no Modifier lengthens an industry's track.
|
||||
*/
|
||||
function usableGrant(f: Facility, m: ModifierProfile): { out: number; in: number } {
|
||||
return {
|
||||
@@ -2328,14 +2547,13 @@ function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKin
|
||||
f.laborers += m.addLoaders;
|
||||
f.porters += m.addPorters;
|
||||
/**
|
||||
* The siding grows only where there IS one. A Passenger Facility has no industry track — passengers
|
||||
* board off the platform — so a Waiting Area, whose "+1" is a passenger slot rather than a car,
|
||||
* must not give the Office somewhere to spot a car. It did, and the board then drew the Depot a
|
||||
* siding square: the same phantom siding the renderer was just fixed for, arriving by another door.
|
||||
* A MODIFIER ADDS A BOX, NEVER ROOM FOR A CAR.
|
||||
*
|
||||
* A Truck Dock beside a Grocer's Warehouse gives it a second RED box — somewhere for one more
|
||||
* arriving load to be cleared to — and changes nothing about how many cars may be set out there,
|
||||
* which was already four and stays four. This used to lengthen the industry track by the same
|
||||
* amount, which is where the phantom siding came from: capacity and rail are different things.
|
||||
*/
|
||||
if (f.kind === 'freight' && (use.out > 0 || use.in > 0)) {
|
||||
f.industryTrack.length += use.out + use.in;
|
||||
}
|
||||
}
|
||||
|
||||
/** §2.1, Gap 4a — extending the Running Track pushes the Limits sign outward. */
|
||||
|
||||
Reference in New Issue
Block a user