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:
+16
-27
@@ -253,34 +253,23 @@ function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
}
|
||||
}
|
||||
|
||||
// §7 — "Once all timetabled trains are created, if there is a played Extra Train card and an
|
||||
// available Crew Tray, the player who played the card may place the Crew Tray in either division
|
||||
// point for immediate departure." An Extra runs once and is gone.
|
||||
//
|
||||
// SIMPLIFICATION, flagged: the rules let the player choose the direction. This launches eastbound
|
||||
// from the West Division Point. Giving the player the choice needs its own intent.
|
||||
/**
|
||||
* §7 — "if there is a played Extra Train card and an available Crew Tray, the player who played
|
||||
* the card may place the Crew Tray in either division point for immediate departure."
|
||||
*
|
||||
* THIS USED TO LAUNCH EVERY EXTRA EASTBOUND FROM THE WEST DIVISION POINT, with the simplification
|
||||
* flagged in a comment. Jesse's rule: an Extra runs the way its NUMBER says, exactly as a
|
||||
* timetabled train does — odd runs west, even runs east (§2.3) — so it starts at the Division
|
||||
* Point it runs FROM. It may instead start at any Control Point, which is any Office above a
|
||||
* Whistle Post, at the player's choice.
|
||||
*
|
||||
* That is a decision, so the phase stops for it the same way it stops to have cars placed. The
|
||||
* chooser is the acting player; §7 says the player who PLAYED the card, which is the same person
|
||||
* in solitaire and needs `pendingExtras` to carry a player before it is not.
|
||||
*/
|
||||
if (s.pendingExtras.length > 0 && s.freeTrays.length > 0) {
|
||||
const number = s.pendingExtras.shift()!;
|
||||
const trayId = s.freeTrays.pop()!;
|
||||
s.trays.set(trayId, {
|
||||
id: trayId,
|
||||
trainNumber: number,
|
||||
trainIsExtra: true,
|
||||
engineAt: 0,
|
||||
consist: [],
|
||||
direction: 'east',
|
||||
position: { at: 'divisionPoint', side: 'west' },
|
||||
movesUsed: 0,
|
||||
});
|
||||
const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === 'west');
|
||||
if (dp?.kind === 'divisionPoint') dp.holding.push(trayId);
|
||||
events.push({
|
||||
type: 'trainMadeUp',
|
||||
trainNumber: number,
|
||||
isExtra: true,
|
||||
at: 'West Division Point',
|
||||
direction: 'east',
|
||||
});
|
||||
s.clock.currentActor = actorAt(s, s.clock.actorOffset % s.players.length);
|
||||
return { events, needsInput: true };
|
||||
}
|
||||
|
||||
// Gap 9 — the car-placement round REPEATS until the consist is full or no suitable car remains.
|
||||
|
||||
+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. */
|
||||
|
||||
+154
-11
@@ -230,10 +230,37 @@ export type IndustryProfile = {
|
||||
export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
|
||||
{ kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 6 },
|
||||
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 6 },
|
||||
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 },
|
||||
/**
|
||||
* BOTH DIRECTIONS, per the card reference — this was `outbound` and it contradicted the rules.
|
||||
*
|
||||
* `card-reference.md`: "Oil Refinery | Tank car | Both | 3 | 2 | 2 | 4", and in prose — "'Freight
|
||||
* House' is not a card. It is the collective term for a freight facility that loads *and* unloads
|
||||
* — the Grocer's Warehouse and the Oil Refinery." §9.3's "Passenger Facilities and Freight Houses
|
||||
* permit cars to move each direction" therefore names exactly these two, and the engine had both
|
||||
* of them one-way.
|
||||
*
|
||||
* The consequence was silent: `usableGrant` drops a Modifier's grant on a direction its host
|
||||
* cannot use, so every +1 inbound beside a Refinery went nowhere.
|
||||
*
|
||||
* The base numbers stay at the engine's own scale (1 per direction it allows) rather than the card
|
||||
* reference's 2/2 — every industry here is scaled down the same way, Mine Tipple included, and
|
||||
* raising one of them alone would be a balance change rather than a correction. Flagged in TODO.
|
||||
*/
|
||||
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 },
|
||||
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 6 },
|
||||
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 3 },
|
||||
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 },
|
||||
/**
|
||||
* BOTH DIRECTIONS — see the Refinery above; "Grocer's Warehouse | Boxcar | Both | 2 | 2 | 2 | 3".
|
||||
*
|
||||
* Reported from play: "grocer's warehouse didn't get extra outbound slot for truck dock." It could
|
||||
* not: the Truck Dock printed +1 outbound at the time and this was `flow: 'inbound'`, so the grant
|
||||
* was dropped on a direction the facility did not have. The same trap still swallows an Ice House
|
||||
* set beside a Grocer's that has been left one-way.
|
||||
*
|
||||
* `TODO.md` had previously recorded this as "checked, and there is no bug" on the reasoning that a
|
||||
* Grocer's is inbound-only. That premise was the bug.
|
||||
*/
|
||||
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 },
|
||||
];
|
||||
|
||||
/** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */
|
||||
@@ -280,7 +307,20 @@ export const MODIFIER_PROFILES: readonly ModifierProfile[] = [
|
||||
{ kind: 'restaurant', name: 'Restaurant', hosts: ['office'], addOut: 1, addIn: 0, addLoaders: 0, addPorters: 1, copies: 2 },
|
||||
{ kind: 'hotel', name: 'Hotel', hosts: ['office'], addOut: 1, addIn: 0, addLoaders: 0, addPorters: 1, copies: 1 },
|
||||
// Freight House / Packing Sheds / Grocer's
|
||||
{ kind: 'truckDock', name: 'Truck dock', hosts: ['freightHouse', 'packingSheds', 'grocersWarehouse'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 2 },
|
||||
/**
|
||||
* INBOUND, AND NO LABORER — the one Modifier that helps a facility RECEIVE.
|
||||
*
|
||||
* It printed "+1 outbound, +1 Laborer" like every other freight Modifier, which made it a
|
||||
* duplicate of Forklifts with a longer host list. A dock is where a truck backs up to take
|
||||
* delivery, so it earns its own line in the deck by adding the red box instead of the green one —
|
||||
* and pays for it by bringing no man to work it.
|
||||
*
|
||||
* Two consequences, both intended. `usableGrant` drops an inbound grant on a host that only ships,
|
||||
* so beside **Packing Sheds** this card now does nothing at all — the hand tooltip says so before
|
||||
* it is played. And it is the first Modifier that grants no worker, so a facility's laborer count
|
||||
* no longer rises with every card set beside it.
|
||||
*/
|
||||
{ kind: 'truckDock', name: 'Truck dock', hosts: ['freightHouse', 'packingSheds', 'grocersWarehouse'], addOut: 0, addIn: 1, addLoaders: 0, addPorters: 0, copies: 2 },
|
||||
{ kind: 'railroadExpressAgency', name: 'Railroad Express Agency', hosts: ['freightHouse'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 1 },
|
||||
{ kind: 'forklifts', name: 'Forklifts', hosts: ['freightHouse', 'packingSheds'], addOut: 1, addIn: 0, addLoaders: 1, addPorters: 0, copies: 2 },
|
||||
// Mine Tipple
|
||||
@@ -402,6 +442,22 @@ export const EXTRA_TRAINS: readonly TrainProfile[] = [
|
||||
|
||||
export const ALL_TRAINS: readonly TrainProfile[] = [...TIMETABLED_TRAINS, ...EXTRA_TRAINS];
|
||||
|
||||
/**
|
||||
* §2.3 — ODD RUNS WEST, EVEN RUNS EAST. The number is the direction, for an Extra as much as for a
|
||||
* timetabled train, and the Division Point it starts at is therefore the one it runs away from.
|
||||
*
|
||||
* Extras print `direction: 'playerChoice'`, which the engine read as "always eastbound from the West
|
||||
* Division Point". Jesse's ruling: the number decides, like everything else on the timetable.
|
||||
*/
|
||||
export function runDirection(trainNumber: number): Direction {
|
||||
return trainNumber % 2 === 0 ? 'east' : 'west';
|
||||
}
|
||||
|
||||
/** The Division Point a train of this number starts from — the opposite end to the way it runs. */
|
||||
export function startingDivisionPoint(trainNumber: number): Direction {
|
||||
return runDirection(trainNumber) === 'east' ? 'west' : 'east';
|
||||
}
|
||||
|
||||
export function trainProfile(number: number, isExtra: boolean): TrainProfile | null {
|
||||
return ALL_TRAINS.find((t) => t.number === number && t.isExtra === isExtra) ?? null;
|
||||
}
|
||||
@@ -503,6 +559,59 @@ export function crossingStages(
|
||||
return Math.max(1, stages - gradeReduction(profile, modifiers, direction, gradeUp));
|
||||
}
|
||||
|
||||
/**
|
||||
* WHAT THIS MAINLINE CARD DOES TO A TRAIN, in a sentence.
|
||||
*
|
||||
* Reported from play: "mainline cards need a tooltip stating what they do. Hilly and Uncontrolled
|
||||
* Siding — I have no idea the impact they have on game play." Both are invisible without one: Hilly
|
||||
* charges freight double what it charges passengers, and Uncontrolled Siding is one of only two
|
||||
* cards where a following train is not stuck behind a slower one.
|
||||
*
|
||||
* The crossing times are COMPUTED by `crossingStages` rather than written out, so a tooltip cannot
|
||||
* drift from the rule it describes — including the Hilly split, which is the whole point of the
|
||||
* card and is decided by whether the train carries a coach.
|
||||
*/
|
||||
export function mainlineDescription(kind: MainlineKind, gradeUp: Direction = 'east'): string {
|
||||
const p = mainlineProfile(kind);
|
||||
const stages = (n: number): string => `${n} Stage${n === 1 ? '' : 's'}`;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (p.speed.kind === 'byTrainType') {
|
||||
// Hilly. The split is by CONSIST, not by the train's speed class: anything with a coach on it
|
||||
// takes the passenger figure.
|
||||
parts.push(
|
||||
`P${p.speed.passenger} / F${p.speed.freight} — a train carrying ANY coach crosses as a ` +
|
||||
`${p.speed.passenger} (${stages(crossingStages(kind, 'fast', true))} for a fast train), and a ` +
|
||||
`freight-only train as a ${p.speed.freight} (${stages(crossingStages(kind, 'fast', false))}). ` +
|
||||
`A slow train adds one Stage either way.`,
|
||||
);
|
||||
} else if (p.speed.kind === 'grade') {
|
||||
parts.push(
|
||||
`A grade, climbing ${gradeUp === 'east' ? 'eastward' : 'westward'}. It crosses as a 30 — ` +
|
||||
`${stages(crossingStages(kind, 'fast', false, [], gradeUp, gradeUp))} for a fast train, and one ` +
|
||||
`more for a slow one. Brakeman and Airbrakes each take a Stage off a train running DOWNHILL; ` +
|
||||
`Helpers takes one off a train running UPHILL. Never below one Stage.`,
|
||||
);
|
||||
} else {
|
||||
parts.push(
|
||||
`${p.speed.value} — ${stages(crossingStages(kind, 'fast', false))} for a fast train, ` +
|
||||
`${stages(crossingStages(kind, 'slow', false))} for a slow one.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (p.trainsMayPass) {
|
||||
parts.push(
|
||||
'TRAINS MAY PASS — two trains may stand on this card at once, so a following train is not held ' +
|
||||
'behind a slower one. Only this and the Double Track allow it.',
|
||||
);
|
||||
} else {
|
||||
parts.push('One train at a time — anything following has to wait for it to clear.');
|
||||
}
|
||||
|
||||
if (p.sortsCars) parts.push('Cars may be sorted into any new order here.');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Q11, answered: the Heavy Grade card prints "(Up)" and "Player sets orientation", so which way it
|
||||
* climbs is a property of the placed card, not a constant. `gradeUp` is the direction a train is
|
||||
@@ -565,7 +674,22 @@ export const REALIGNMENTS: readonly { from: MainlineKind; to: MainlineKind }[] =
|
||||
// The four new card categories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SimpleCard = { key: string; name: string; copies: number; placement: string; effect: string };
|
||||
export type SimpleCard = {
|
||||
key: string;
|
||||
name: string;
|
||||
copies: number;
|
||||
placement: string;
|
||||
effect: string;
|
||||
/**
|
||||
* The opponent-directed card this exists SOLELY to answer.
|
||||
*
|
||||
* A defence with nothing to defend against is a dead draw, exactly as the attack itself would be.
|
||||
* The 22 Space-use and Action cards are held out of every deck until they are implemented (Q6),
|
||||
* and these go with them — named here rather than in a list somewhere else so the pairing is
|
||||
* visible on the card, and so they come back together when their attacker does.
|
||||
*/
|
||||
answers?: string;
|
||||
};
|
||||
|
||||
/** "Burns tablespace" — occupies a grid cell and does nothing useful. Semantics pending §10 Q6. */
|
||||
export const SPACE_USE_CARDS: readonly SimpleCard[] = [
|
||||
@@ -672,11 +796,11 @@ export function enhancementRule(key: string): EnhancementRule | null {
|
||||
|
||||
export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'interlocking', name: 'Interlocking', copies: 2, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
|
||||
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.' },
|
||||
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.', answers: 'Derail' },
|
||||
{ key: 'yardOffice', name: 'Yard office', copies: 1, placement: 'any Secondary Track Straight', effect: 'An inbound train with no coaches that can reach the yard office in one move may arrive there instead of the Train Order Office.' },
|
||||
{ key: 'smallYard', name: 'Small yard', copies: 1, placement: 'any Secondary Track Straight', effect: 'A train that spends one move in the yard may sort itself into ANY order, including cars ahead of the engine.' },
|
||||
{ key: 'waterColumn', name: 'Water column', copies: 2, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.' },
|
||||
{ key: 'overpass', name: 'Overpass', copies: 1, placement: 'any Railroad Crossing', effect: 'Removes the restrictions of a played Railroad Crossing.' },
|
||||
{ key: 'waterColumn', name: 'Water column', copies: 2, placement: 'any Running Track Straight', effect: 'Lets you remove any Watertower in your district.', answers: 'Watertower' },
|
||||
{ key: 'overpass', name: 'Overpass', copies: 1, placement: 'any Railroad Crossing', effect: 'Removes the restrictions of a played Railroad Crossing.', answers: 'Railroad crossing' },
|
||||
{ key: 'telegraph', name: 'Telegraph', copies: 3, placement: 'any Running Track Straight', effect: 'Once a day, when dispatching facing trains, add +4 to the other train’s number.' },
|
||||
{ key: 'telephone', name: 'Telephone', copies: 2, placement: 'on Telegraph', effect: 'Once a day, add +8 to the other train’s number.' },
|
||||
{ key: 'radio', name: 'Radio', copies: 2, placement: 'on Telephone', effect: 'Once a day, add +12 to the other train’s number.' },
|
||||
@@ -688,7 +812,7 @@ export const MAINLINE_MODIFIER_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'airbrakes', name: 'Airbrakes', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage downhill. Brakeman must be in effect.' },
|
||||
{ key: 'helpers', name: 'Helpers', copies: 1, placement: 'a GRADE Mainline card', effect: 'Faster passage uphill.' },
|
||||
{ key: 'realignment', name: 'Realignment', copies: 2, placement: 'a Mainline card', effect: 'Convert one Mainline type to another. Not while a train is on it.' },
|
||||
{ key: 'facingPointLocksMainline', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Prevents Derail being played on you.' },
|
||||
{ key: 'facingPointLocksMainline', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Prevents Derail being played on you.', answers: 'Derail' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -915,6 +1039,12 @@ export function isOpponentOnly(category: string): boolean {
|
||||
return OPPONENT_ONLY_CATEGORIES.includes(category);
|
||||
}
|
||||
|
||||
/** How many cards `DEFENCE_ONLY_CARDS` accounts for — 7: two Facing Point Locks of each kind, two
|
||||
* Water Columns and one Overpass. */
|
||||
export const DEFENCE_ONLY_COPIES =
|
||||
ENHANCEMENT_CARDS.filter((c) => c.answers).reduce((n, c) => n + c.copies, 0) +
|
||||
MAINLINE_MODIFIER_CARDS.filter((c) => c.answers).reduce((n, c) => n + c.copies, 0);
|
||||
|
||||
export function deckComposition(): { category: string; count: number }[] {
|
||||
const sum = (xs: readonly { copies: number }[]): number => xs.reduce((n, x) => n + x.copies, 0);
|
||||
return [
|
||||
@@ -945,6 +1075,19 @@ export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0);
|
||||
* implemented — `checkPlay` answers both categories NOT_IMPLEMENTED, so dealing them would make ~9%
|
||||
* of draws reject. See `buildDeck`.
|
||||
*/
|
||||
export const SOLITAIRE_DECK_SIZE = deckComposition()
|
||||
.filter((c) => !isOpponentOnly(c.category))
|
||||
.reduce((n, c) => n + c.count, 0);
|
||||
export const SOLITAIRE_DECK_SIZE =
|
||||
deckComposition()
|
||||
.filter((c) => !isOpponentOnly(c.category))
|
||||
.reduce((n, c) => n + c.count, 0) - DEFENCE_ONLY_COPIES;
|
||||
|
||||
/**
|
||||
* Every card held back BECAUSE its attacker is held back — see `SimpleCard.answers`.
|
||||
*
|
||||
* Reported from play: "just like the opponent directed cards are removed from the solitaire game,
|
||||
* remove any of the defensive cards whose only purpose is to answer them. No need to have them in
|
||||
* the deck when they can never be used."
|
||||
*/
|
||||
export const DEFENCE_ONLY_CARDS: readonly SimpleCard[] = [
|
||||
...ENHANCEMENT_CARDS.filter((c) => c.answers),
|
||||
...MAINLINE_MODIFIER_CARDS.filter((c) => c.answers),
|
||||
];
|
||||
|
||||
+23
-1
@@ -21,7 +21,7 @@
|
||||
|
||||
import type { CarType, OfficeTier } from './content.ts';
|
||||
import type { LocalOpsOption } from './intents.ts';
|
||||
import type { CardId, GridCoord, PlayerIndex, RollingStock, TrayId } from './state.ts';
|
||||
import type { CardId, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
||||
|
||||
export type GameEvent =
|
||||
// -- clock
|
||||
@@ -43,6 +43,26 @@ export type GameEvent =
|
||||
* running forward: the engine meets the cars head-on and takes them on the front.
|
||||
*/
|
||||
toNose: boolean;
|
||||
/**
|
||||
* Cars that STAY on the card the crew pulled out of, when it coupled its own cut.
|
||||
*
|
||||
* A train may set out off both ends on one square, and pulling forward picks up only the cut
|
||||
* at the end it leaves by — the one behind it is still standing there. `from` names the cards
|
||||
* to sweep and every other one is emptied outright, so the start card needs the exception said
|
||||
* explicitly rather than recomputed: the tray has already moved by the time this is reduced,
|
||||
* and `standingWest` went with it.
|
||||
*/
|
||||
leaves?: { at: GridCoord; stock: RollingStock[] };
|
||||
/**
|
||||
* The train's OWN cut, lifted back off the square it set it out on.
|
||||
*
|
||||
* Recoupling your own cars on the square you are standing on is UNDOING the drop, not a fresh
|
||||
* pick-up (Jesse's call, of the two the plan put up). Trains 3/4 spend a per-location freight
|
||||
* budget on setting out, so without this a legal-looking drop became silently one-way: the
|
||||
* train could only ever back away from its own cut, never pull forward through it. The budget
|
||||
* is refunded here and not charged again at the far end.
|
||||
*/
|
||||
recoupled?: { at: GridCoord; stock: RollingStock[] };
|
||||
}
|
||||
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[]; fromNose?: boolean }
|
||||
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
@@ -121,6 +141,8 @@ export type GameEvent =
|
||||
* always followed by one `revenueChanged` per player.
|
||||
*/
|
||||
| { type: 'trainCompleted'; trainNumber: number; isExtra: boolean; side: 'east' | 'west'; consist: RollingStock[] }
|
||||
/** An Extra took a Crew Tray and started its run — at a Division Point, or at a Control Point. */
|
||||
| { type: 'extraStarted'; player: PlayerIndex; trainNumber: number; atSeat: SeatIndex | null }
|
||||
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
|
||||
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
|
||||
| { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number }
|
||||
|
||||
+20
-5
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { CarType, Direction, Hand, OfficeTier, TrackGeometry } from './content.ts';
|
||||
import type { CardId, GridCoord, PlayerIndex, TrayId } from './state.ts';
|
||||
import type { CardId, GridCoord, PlayerIndex, SeatIndex, TrayId } from './state.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local Operations Phase (§6) — a three-way exclusive choice
|
||||
@@ -66,6 +66,13 @@ export type Intent =
|
||||
// -- New Train Phase (§7)
|
||||
| { type: 'newTrain.placeCar'; trayId: TrayId; carType: CarType; loaded: boolean }
|
||||
| { type: 'newTrain.passCar'; trayId: TrayId }
|
||||
/**
|
||||
* §7 — "the player who played the card may place the Crew Tray in either division point for
|
||||
* immediate departure", extended by Jesse: an Extra starts at the Division Point its NUMBER sends
|
||||
* it to (odd runs west, even east, exactly as a timetabled train), or at any Control Point — any
|
||||
* Office above a Whistle Post — at the player's choice. `atSeat` null means the Division Point.
|
||||
*/
|
||||
| { type: 'newTrain.startExtra'; trainNumber: number; atSeat: SeatIndex | null }
|
||||
/** Q9 — run a second, identical section behind a train that is due out this Stage. */
|
||||
| { type: 'newTrain.secondSection'; trainNumber: number }
|
||||
// -- Mainline Phase (§8.1) — the Superintendent's clearance ruling
|
||||
@@ -171,9 +178,13 @@ export type RejectionCode =
|
||||
* travel, 24 with passengers waiting and every coach on the train already full.
|
||||
*/
|
||||
/**
|
||||
* §9.3 — the green box may only be stocked, and a load may only start down the sign, when an empty
|
||||
* car of THAT type is standing on the industry's track and is not already promised to another
|
||||
* load. Fetching cargo you cannot ship is "just dropping it onto the tracks".
|
||||
* §9.3 — a load may only start down the sign when an empty car of THAT type is standing on the
|
||||
* industry's track and is not already promised to another load. Walking cargo onto WORK with
|
||||
* nothing to put it in is "just dropping it onto the tracks".
|
||||
*
|
||||
* STOCKING THE GREEN BOX IS NOT GATED ON IT. §6.3 lets the Freight Agent stage a load whenever
|
||||
* the Division Yard has the car and the box has room; the empty car only has to be there before
|
||||
* the Laborers can move it out of the box.
|
||||
*/
|
||||
| 'NO_EMPTY_CAR_SPOTTED'
|
||||
| 'NO_PORTERS_HERE'
|
||||
@@ -181,7 +192,11 @@ export type RejectionCode =
|
||||
| 'NO_EMPTY_COACH'
|
||||
| 'NO_LOADED_COACH'
|
||||
| 'INBOUND_BOX_FULL'
|
||||
| 'NO_EMPTY_COACH_IN_YARD';
|
||||
| 'NO_EMPTY_COACH_IN_YARD'
|
||||
| 'NOT_A_CONTROL_POINT'
|
||||
| 'NO_EXTRA_PENDING'
|
||||
| 'NO_FREE_TRAY'
|
||||
| 'NO_FREE_AD_TRACK';
|
||||
|
||||
export type Rejection = { code: RejectionCode; message: string };
|
||||
|
||||
|
||||
@@ -229,6 +229,17 @@ function newTrainCandidates(s: GameState): Intent[] {
|
||||
if (due !== null && due !== undefined) {
|
||||
out.push({ type: 'newTrain.secondSection', trainNumber: due });
|
||||
}
|
||||
/**
|
||||
* Where a pending Extra starts: its own Division Point, decided by its number, or any Control
|
||||
* Point. `check` refuses a Whistle Post and a full Office, so every seat is offered and the rules
|
||||
* do the filtering — one implementation of "is this a Control Point", not two.
|
||||
*/
|
||||
for (const trainNumber of s.pendingExtras) {
|
||||
out.push({ type: 'newTrain.startExtra', trainNumber, atSeat: null });
|
||||
for (const seat of s.officeAreas.keys()) {
|
||||
out.push({ type: 'newTrain.startExtra', trainNumber, atSeat: seat });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
+16
-3
@@ -60,8 +60,9 @@ export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] {
|
||||
* Q6 took them out of solitaire because they have no legal target in a one-player game. They are
|
||||
* out of the competitive deck too because `checkPlay` answers both categories `NOT_IMPLEMENTED`:
|
||||
* leaving them in would make ~9% of draws reject outright, which is worse than not dealing them.
|
||||
* Three Enhancements — Facing Point Locks, Water Column and Overpass — exist only to answer these,
|
||||
* and stay dormant until they come back. Recorded in TODO.md as multiplayer work.
|
||||
*
|
||||
* AND SO ARE THE SEVEN CARDS THAT ANSWER THEM — see below. They used to be dealt and sit dormant,
|
||||
* which is the same dead draw by another name. Recorded in TODO.md as multiplayer work.
|
||||
*
|
||||
* `mode` is still taken so the signature does not move when they return; it is deliberately unused
|
||||
* for these two categories today.
|
||||
@@ -90,10 +91,22 @@ export function buildDeck(mode: GameConfig['mode'] = 'competitive'): Card[] {
|
||||
for (let i = 0; i < c.copies; i++) push({ kind: 'spaceUse', key: c.key });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* A DEFENCE GOES OUT WITH THE ATTACK IT ANSWERS.
|
||||
*
|
||||
* Facing Point Locks (both kinds), the Water Column and the Overpass exist only to answer Derail,
|
||||
* a Watertower and a Railroad Crossing — all of which are among the 22 opponent-directed cards
|
||||
* held out above. Left in, they are dead draws for exactly the same reason the attacks would be,
|
||||
* and there are 7 of them. `SimpleCard.answers` names the pairing on the card itself, so they come
|
||||
* back together the moment `opponentCardsInDeck` does.
|
||||
*/
|
||||
const dealt = (c: { answers?: string }): boolean => opponentCardsInDeck || !c.answers;
|
||||
for (const c of ENHANCEMENT_CARDS) {
|
||||
if (!dealt(c)) continue;
|
||||
for (let i = 0; i < c.copies; i++) push({ kind: 'enhancement', key: c.key });
|
||||
}
|
||||
for (const c of MAINLINE_MODIFIER_CARDS) {
|
||||
if (!dealt(c)) continue;
|
||||
for (let i = 0; i < c.copies; i++) push({ kind: 'mainlineModifier', key: c.key });
|
||||
}
|
||||
for (const c of MANEUVER_CARDS) {
|
||||
@@ -191,7 +204,7 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
|
||||
// WORK sign is printed "For Freight Facilities" (rules-v0.2.md:452). This was a three-slot array
|
||||
// of nulls, which read as "three empty boxes" to every renderer that looked.
|
||||
menAtWork: null,
|
||||
industryTrack: { length: 0, cars: [] },
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 0,
|
||||
porters: p.porters,
|
||||
usedThisStage: { laborers: 0, porters: 0 },
|
||||
|
||||
+120
-13
@@ -18,7 +18,7 @@ import type {
|
||||
OfficeTier,
|
||||
TrackGeometry,
|
||||
} from './content.ts';
|
||||
import { officeProfile } from './content.ts';
|
||||
import { MAX_CONSIST, officeProfile } from './content.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identifiers
|
||||
@@ -119,7 +119,26 @@ export type TrackCard = {
|
||||
* Operational Rail (§9.3). Use isOperationalRail() rather than reading a stored flag.
|
||||
*/
|
||||
baseOperationalRail: boolean;
|
||||
/** Uncoupled cars left here, in track order (§A.3). */
|
||||
/**
|
||||
* Uncoupled cars left standing here, **ordered WEST to EAST** (§A.3).
|
||||
*
|
||||
* §A.3 states the requirement outright: "cars are loaded into and unloaded from the Crew Tray, and
|
||||
* occupy the track, in the same order they originally held, **left-to-right**". Left-to-right is
|
||||
* west-to-east, and this array had no defined orientation at all — the doc comment said "in track
|
||||
* order" and named no direction, so nothing performed the conversion.
|
||||
*
|
||||
* That is one bug wearing three faces, all reported from play. Setting out four cars at once
|
||||
* parked them in a different order than setting out one car four times, because `carsDropped`
|
||||
* always pushed onto the end. A train that ran onto a parked cut got the SAME consist whichever
|
||||
* way it approached, when the two must mirror. And the board drew the tray strip flipped for an
|
||||
* east-facing train and the card's cars unflipped, so the same cut read one way in the tray and
|
||||
* the other on the card.
|
||||
*
|
||||
* WEST-TO-EAST IS THE BOARD'S ORIENTATION, not the train's, which is exactly why it is the right
|
||||
* one: it is a property of the card, so it does not change when a different train touches it. The
|
||||
* two conversions are stated once, in `apply.ts`'s `carsDropped` and `carsCoupled` reducers, and
|
||||
* `CrewTray.standingWest` records where a train standing here sits among them.
|
||||
*/
|
||||
standing: RollingStock[];
|
||||
facility: Facility | null;
|
||||
modifiers: ModifierKind[];
|
||||
@@ -179,8 +198,19 @@ export type Facility = {
|
||||
* reported from playtesting. Typing it away means a fourth renderer cannot reintroduce the bug.
|
||||
*/
|
||||
menAtWork: [Load | null, Load | null, Load | null] | null;
|
||||
/** Where cars are spotted for loading and unloading. */
|
||||
industryTrack: { length: number; cars: RollingStock[] };
|
||||
/**
|
||||
* Where cars are spotted for loading and unloading.
|
||||
*
|
||||
* NO LENGTH. This used to carry `length: baseOut + baseIn`, which quietly made an industry's
|
||||
* SIDING as long as its box count — so a Mine Tipple (one green box) had room for exactly one
|
||||
* car, and a crew standing on it with two hoppers to set out could only put down one. Reported
|
||||
* from play at undo 188. The box count is how much WORK an industry can hold, not how much RAIL
|
||||
* it has; conflating the two invented a printed siding that no industry card actually has.
|
||||
*
|
||||
* An industry track is ordinary Operating Rail and holds what any card holds — see `spaceOn`.
|
||||
* Modifiers raise `capacity` (another red or green box) and never the room for cars.
|
||||
*/
|
||||
industryTrack: { cars: RollingStock[] };
|
||||
laborers: number;
|
||||
porters: number;
|
||||
/** Resets at the start of each Stage (§9.1). */
|
||||
@@ -192,25 +222,35 @@ export type Facility = {
|
||||
* Operational Rail status. This is why isOperationalRail is a function, not a stored field.
|
||||
*/
|
||||
/**
|
||||
* The cars physically standing on a card.
|
||||
* The cars physically standing on a card, **ordered WEST to EAST**.
|
||||
*
|
||||
* On a Facility card the industry track IS where cars stand — spotting a car there and leaving a
|
||||
* car there are the same act (§9.3). Modelling them as two separate places was a bug: cars dropped
|
||||
* at a facility went into `standing`, while loading looked for them on `industryTrack`, so no
|
||||
* freight load could ever complete and freight revenue was structurally zero.
|
||||
*
|
||||
* THE ORIENTATION IS THE BOARD'S, NOT THE TRAIN'S, and it is `standing`'s alone to keep — see the
|
||||
* comment on that field. `industryTrack.cars` inherits it through this function, so every reader of
|
||||
* either place gets the same convention and the two can never drift apart.
|
||||
*/
|
||||
export function carsOn(card: TrackCard): RollingStock[] {
|
||||
return card.facility && card.facility.industryTrack.length > 0
|
||||
? card.facility.industryTrack.cars
|
||||
: card.standing;
|
||||
// Gated on the facility being FREIGHT, not on a track length that no longer exists. A Passenger
|
||||
// Facility has no industry track at all — passengers board off the platform — so cars left on a
|
||||
// Depot stand on the card like they would anywhere else.
|
||||
return card.facility && card.facility.kind === 'freight' ? card.facility.industryTrack.cars : card.standing;
|
||||
}
|
||||
|
||||
/** How many more cars this card can hold. Ordinary track is unbounded; an industry track is not. */
|
||||
/**
|
||||
* How many more cars this card can hold.
|
||||
*
|
||||
* ONE RULE FOR EVERY OPERATING TRACK CARD, industry or not: a card holds up to `MAX_CONSIST` cars,
|
||||
* because a consist may not exceed four and four is therefore all that can ever be shoved onto one.
|
||||
* An industry used to be the exception — its room came from its box count, so a one-box industry
|
||||
* refused a second car — and that exception was the bug. Ordinary track used to be the other
|
||||
* exception, unbounded, which let a pile build up that no train could then legally couple.
|
||||
*/
|
||||
export function spaceOn(card: TrackCard): number {
|
||||
if (card.facility && card.facility.industryTrack.length > 0) {
|
||||
return card.facility.industryTrack.length - card.facility.industryTrack.cars.length;
|
||||
}
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
return MAX_CONSIST - carsOn(card).length;
|
||||
}
|
||||
|
||||
export function isOperationalRail(card: TrackCard): boolean {
|
||||
@@ -305,6 +345,25 @@ export type CrewTray = {
|
||||
* puts the engine on the other end, which is the one thing the arrow exists to show.
|
||||
*/
|
||||
railFacing?: 'e' | 'w';
|
||||
/**
|
||||
* HOW MANY OF THIS CARD'S STANDING CARS LIE WEST OF THIS TRAIN.
|
||||
*
|
||||
* `carsOn(card)` runs west to east and a train standing on the card sits somewhere IN that row:
|
||||
* cars `[0, standingWest)` are west of the engine, cars `[standingWest, end)` are east of it. A
|
||||
* train can set out off both ends on the same square — §A.3 lets a cut come off either outer end —
|
||||
* so "which side is this cut on" is not recoverable from the array alone, and it is the question
|
||||
* every remaining rule asks.
|
||||
*
|
||||
* It answers all three: which cars are AHEAD of the engine and which BEHIND (combine with
|
||||
* `railFacing`), which cut a departing train must couple back up (the one at the end it leaves
|
||||
* by), and which side of the chip the renderer draws the cut on.
|
||||
*
|
||||
* Kept on the TRAY rather than the card because it describes a relationship, not the card: with no
|
||||
* train standing there, a card's cars are simply a cut with no near or far side, and the next
|
||||
* train to arrive couples all of them regardless. Reset to 0 on every move, which is sound because
|
||||
* coupling is mandatory — a train arrives on a card it has just emptied.
|
||||
*/
|
||||
standingWest?: number;
|
||||
position: NodeRef;
|
||||
movesUsed: number;
|
||||
/**
|
||||
@@ -570,6 +629,54 @@ export function railFacingOf(tray: Pick<CrewTray, 'railFacing' | 'facing' | 'dir
|
||||
return tray.direction === 'west' ? 'w' : 'e';
|
||||
}
|
||||
|
||||
/**
|
||||
* THE ONE CONVERSION BETWEEN A TRAY AND A TRACK, stated once and used by both directions.
|
||||
*
|
||||
* `consist` is ordered NOSE FIRST and the nose points `facing`; `standing` is ordered WEST TO EAST.
|
||||
* So for an east-facing train the tray reads east-to-west and has to be reversed to go onto the
|
||||
* card; for a west-facing train it already reads west-to-east and goes on as it stands.
|
||||
*
|
||||
* It is its own inverse, which is why lifting cars off a card uses the same function.
|
||||
*/
|
||||
export function trackOrder(cut: readonly RollingStock[], facing: 'e' | 'w'): RollingStock[] {
|
||||
return facing === 'e' ? [...cut].reverse() : [...cut];
|
||||
}
|
||||
|
||||
/**
|
||||
* The cars standing west and east of a train, split at `standingWest`.
|
||||
*
|
||||
* Clamped rather than trusted: a tray that has never dropped anything carries no `standingWest` at
|
||||
* all, and a saved game from before this field existed carries none either — both read as 0, which
|
||||
* puts every car on the card east of the engine. That is the same answer the engine gave before the
|
||||
* split existed, so an old save degrades to the old behaviour instead of throwing.
|
||||
*/
|
||||
export function standingSides(
|
||||
tray: { standingWest?: number | undefined },
|
||||
cars: readonly RollingStock[],
|
||||
): { west: RollingStock[]; east: RollingStock[] } {
|
||||
const k = Math.max(0, Math.min(cars.length, tray.standingWest ?? 0));
|
||||
return { west: cars.slice(0, k), east: cars.slice(k) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The cut a train would run into if it left this card through `exit` — the cars between it and that
|
||||
* end of the card.
|
||||
*
|
||||
* Only 'e' and 'w' can hold a cut: the array is a west-to-east row, so a train leaving north or
|
||||
* south off a curve or a spur is not running along it and meets nothing. Returned in the order the
|
||||
* train MEETS them, nearest first, which is what `carsCoupled` wants.
|
||||
*/
|
||||
export function cutTowards(
|
||||
tray: { standingWest?: number | undefined },
|
||||
cars: readonly RollingStock[],
|
||||
exit: 'n' | 's' | 'e' | 'w',
|
||||
): RollingStock[] {
|
||||
const { west, east } = standingSides(tray, cars);
|
||||
if (exit === 'e') return east;
|
||||
if (exit === 'w') return [...west].reverse();
|
||||
return [];
|
||||
}
|
||||
|
||||
export function turnOf(s: GameState, player: PlayerIndex): TurnState {
|
||||
const t = s.turns.get(player);
|
||||
if (!t) throw new Error(`no turn state for player ${player}`);
|
||||
|
||||
+40
-6
@@ -34,7 +34,7 @@ import type {
|
||||
TrackArc,
|
||||
TurnoutOrientation,
|
||||
} from './state.ts';
|
||||
import { carsOn, coordKey, isLockedByWork, isOperationalRail, spaceOn } from './state.ts';
|
||||
import { carsOn, coordKey, cutTowards, isLockedByWork, isOperationalRail, spaceOn } from './state.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ports and geometry
|
||||
@@ -316,6 +316,15 @@ export type MoveContext = {
|
||||
consistSize: number;
|
||||
/** The tray making the move, so it does not block itself. */
|
||||
self: TrayId;
|
||||
/**
|
||||
* How many cars on the START card stand west of this train (`CrewTray.standingWest`).
|
||||
*
|
||||
* The walk begins at `neighbour(start, initialExit)` and never looked at the start card at all, so
|
||||
* a train drove straight through the cut it had just set out — in both directions, and without
|
||||
* counting it against the four-car limit. Coupling is mandatory (§A.4) and a cut on your own
|
||||
* square is no exception: pulling out through the end it sits at picks it up again.
|
||||
*/
|
||||
standingWest?: number;
|
||||
};
|
||||
|
||||
function cardAt(area: OfficeArea, c: GridCoord): TrackCard | undefined {
|
||||
@@ -406,8 +415,20 @@ export function exploreMoves(
|
||||
if (firstCard) block(first, 'noJoin', 'the rails do not meet — two cards touching is not a join, and on a north or south edge both 45° legs must lie on the same diagonal');
|
||||
return { destinations: results, blocked };
|
||||
}
|
||||
/**
|
||||
* THE CUT ON YOUR OWN CARD, which the walk used to ignore entirely.
|
||||
*
|
||||
* Reported from play: "if I put cars off the nose on a given track, and my next move is go
|
||||
* forward, I need to couple those cars right back on". Exactly so — and the engine let the train
|
||||
* drive away and leave them. `cutTowards` takes only the cars between the engine and the end it is
|
||||
* leaving by, so setting out off the nose and then BACKING away is still free, which is the whole
|
||||
* point of setting out off one particular end.
|
||||
*
|
||||
* Ordered nearest-first like every other card's, so it simply seeds the accumulator.
|
||||
*/
|
||||
const ownCut = cutTowards({ standingWest: ctx.standingWest }, carsOn(startCard), initialExit);
|
||||
const queue: Frontier[] = [
|
||||
{ coord: first, entry: opposite(initialExit), path: [], couples: [] },
|
||||
{ coord: first, entry: opposite(initialExit), path: [], couples: ownCut },
|
||||
];
|
||||
|
||||
while (queue.length > 0) {
|
||||
@@ -443,14 +464,27 @@ export function exploreMoves(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mandatory coupling. Rejecting rather than truncating is deliberate: a move that would
|
||||
// overfill the tray is illegal, not a move that picks up fewer cars.
|
||||
const couples = [...node.couples, ...carsOn(card)];
|
||||
/**
|
||||
* Mandatory coupling. Rejecting rather than truncating is deliberate: a move that would
|
||||
* overfill the tray is illegal, not a move that picks up fewer cars.
|
||||
*
|
||||
* NEAREST FIRST ALONG THE DIRECTION OF TRAVEL. `carsOn` runs west to east, so a train entering
|
||||
* through the card's EAST port meets them back to front and the row has to be reversed. Without
|
||||
* this the same parked cut produced an identical consist whichever way it was approached, when
|
||||
* the two must mirror — which is the difference between a run-around being worth a Move and
|
||||
* being pointless.
|
||||
*/
|
||||
const met = node.entry === 'e' ? [...carsOn(card)].reverse() : carsOn(card);
|
||||
const couples = [...node.couples, ...met];
|
||||
if (ctx.consistSize + couples.length > MAX_CONSIST) {
|
||||
const own =
|
||||
ownCut.length > 0
|
||||
? `, ${ownCut.length} of them the cut standing at the end of your OWN card that this move pulls out through`
|
||||
: '';
|
||||
block(
|
||||
node.coord,
|
||||
'tooManyCars',
|
||||
`too many cars — running here couples ${couples.length} standing car(s) onto a train already holding ${ctx.consistSize}, and ${MAX_CONSIST} is the limit. Coupling is mandatory: you may not run past a car and leave it (§A.4)`,
|
||||
`too many cars — running here couples ${couples.length} standing car(s)${own} onto a train already holding ${ctx.consistSize}, and ${MAX_CONSIST} is the limit. Coupling is mandatory: you may not run past a car and leave it (§A.4)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user