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;
|
||||
}
|
||||
|
||||
+50
-22
@@ -151,7 +151,10 @@ export function divisionSvg(nodes: DivisionView[]): string {
|
||||
cap: n.capacity,
|
||||
tip: dp
|
||||
? 'A Division Point — the end of the line. Trains both enter and leave the Division here (odd numbers run west, even run east), and queue without limit'
|
||||
: `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}`,
|
||||
: `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}` +
|
||||
// What the card actually DOES. The name alone left Hilly and Uncontrolled Siding as
|
||||
// words with no gameplay attached — reported exactly that way.
|
||||
(n.what ? `\n\n${n.what}` : ''),
|
||||
seat: null,
|
||||
// A Division Point is one region — the queue trains enter and leave the Division through.
|
||||
regions: dp ? 1 : (n.regions ?? 0),
|
||||
@@ -563,7 +566,7 @@ export function officeSvg(
|
||||
}
|
||||
}
|
||||
// MEN | AT | WORK — one physical sign, worked left to right loading and right to left
|
||||
// unloading. The spotted car sits past WORK, on the siding drawn below.
|
||||
// unloading. The spotted car sits past WORK, standing on the card itself.
|
||||
for (let i = 0; i < f.maw.length; i++) {
|
||||
out += box(bx, by, f.maw[i] !== null, 'bs-maw', ['M', 'A', 'W'][i]);
|
||||
bx += cellW + 2;
|
||||
@@ -623,21 +626,45 @@ export function officeSvg(
|
||||
}
|
||||
}
|
||||
|
||||
// Standing room, drawn as the printed squares: filled means occupied.
|
||||
//
|
||||
// `Math.max(1, …)` applies only to an industry, whose track is at least one car long. A Passenger
|
||||
// Facility has NO industry track — passengers board straight off the platform — so a Depot was
|
||||
// being drawn a siding slot it cannot spot a car on.
|
||||
const spots = cell.facility
|
||||
? cell.facility.kind === 'passenger'
|
||||
? cell.facility.trackCap
|
||||
: Math.max(1, cell.facility.trackCap)
|
||||
: cell.cars.length;
|
||||
for (let i = 0; i < Math.min(spots, 4); i++) {
|
||||
const filled = i < cell.cars.length;
|
||||
const carTip = filled
|
||||
? `${cell.cars[i]} standing here`
|
||||
: 'an empty space on this track — a car may be spotted here';
|
||||
/**
|
||||
* THE CARS STANDING HERE — NO PRINTED SIDING BEHIND THEM.
|
||||
*
|
||||
* An industry card used to draw one empty square per unit of its capacity, which read as a
|
||||
* siding of a particular length printed on the card. No industry card prints one. Worse, the
|
||||
* length came from the box count, so a one-box industry drew a single square and looked like it
|
||||
* could only ever hold one car — which is exactly what the engine was wrongly enforcing.
|
||||
*
|
||||
* Every card, industry or not, now draws what is actually standing on it and nothing more. Four
|
||||
* is the ceiling everywhere (`MAX_CONSIST`), so the row cannot overflow the card.
|
||||
*
|
||||
* The 4 is written out rather than imported: this function's SOURCE is serialized into the
|
||||
* standalone replay page, where module imports are not in scope. Referencing the constant threw
|
||||
* `MAX_CONSIST is not defined` in the rendered HTML while typechecking perfectly here.
|
||||
*/
|
||||
/**
|
||||
* WHICH SIDE OF THE ENGINE THE CUT IS ON.
|
||||
*
|
||||
* Reported from play: "right after dropping my cars I need to be able to see if those cars are
|
||||
* ahead or behind the train." They were drawn as one strip starting at the left edge whether a
|
||||
* train was on the card or not, so the picture could not say.
|
||||
*
|
||||
* `cars` runs WEST TO EAST and `standingWest` is how many of them are west of the train, so the
|
||||
* row is split there: the west cars stay left-aligned, the east cars are pushed over to the
|
||||
* right-hand end, and the gap between them is where the engine is standing. Whether that gap
|
||||
* means "ahead" or "behind" is the engine's facing, which the tooltip says in words rather than
|
||||
* leaving the player to work out from the compass.
|
||||
*/
|
||||
const split = cell.train ? Math.max(0, Math.min(cell.cars.length, cell.standingWest ?? 0)) : 0;
|
||||
const shown = Math.min(cell.cars.length, 4);
|
||||
for (let i = 0; i < shown; i++) {
|
||||
// With no train on the card there is no gap to leave, so the whole cut stays left-aligned.
|
||||
const westOfEngine = cell.train ? i < split : true;
|
||||
const side = !cell.train
|
||||
? 'standing here'
|
||||
: (cell.train.facing === 'e') === westOfEngine
|
||||
? 'standing BEHIND the engine — it would couple onto the tail backing up'
|
||||
: 'standing AHEAD of the engine — it would couple onto the nose pulling forward';
|
||||
const carTip = `${cell.cars[i]} ${side}`;
|
||||
/**
|
||||
* WHICH CAR IS STANDING HERE, not just that one is.
|
||||
*
|
||||
@@ -666,7 +693,7 @@ export function officeSvg(
|
||||
// A caboose is never "loaded" in the freight sense (carLabel says so), so it is drawn solid:
|
||||
// it is a car that is doing its job, not an empty waiting for one.
|
||||
const loaded = /^loaded/.test(car) || /caboose/.test(car);
|
||||
const cls = filled ? ` bs-occ bs-car-${type}${loaded ? ' bs-loaded' : ' bs-mt'}` : '';
|
||||
const cls = ` bs-occ bs-car-${type}${loaded ? ' bs-loaded' : ' bs-mt'}`;
|
||||
/**
|
||||
* LOADED OR EMPTY, TOLD THE SAME WAY THE TRAY TELLS IT.
|
||||
*
|
||||
@@ -678,10 +705,11 @@ export function officeSvg(
|
||||
* Now the card matches the tray — solid face for loaded, hollow for empty — and every slot
|
||||
* carries the car's own words as a tooltip, so "loaded hopper" is one hover away.
|
||||
*/
|
||||
out += `<rect class="bs-slot${cls}" x="${8 + i * 30}" y="${H - 26}" width="26" height="15" rx="2" data-tip="${esc(carTip)}"/>`;
|
||||
if (filled) {
|
||||
out += `<text class="bs-carlab${loaded ? ' bs-lab-ld' : ''}" x="${21 + i * 30}" y="${H - 15}" text-anchor="middle" data-tip="${esc(carTip)}">${esc(type)}</text>`;
|
||||
}
|
||||
// West cars run rightwards from the left edge; east cars run leftwards from the right edge, so
|
||||
// the engine's place in the row is the gap left between them.
|
||||
const sx = westOfEngine ? 8 + i * 30 : W - 34 - (shown - 1 - i) * 30;
|
||||
out += `<rect class="bs-slot${cls}" x="${sx}" y="${H - 26}" width="26" height="15" rx="2" data-tip="${esc(carTip)}"/>`;
|
||||
out += `<text class="bs-carlab${loaded ? ' bs-lab-ld' : ''}" x="${sx + 13}" y="${H - 15}" text-anchor="middle" data-tip="${esc(carTip)}">${esc(type)}</text>`;
|
||||
}
|
||||
|
||||
if (cell.enhancements.length > 0) {
|
||||
|
||||
+110
-15
@@ -19,9 +19,17 @@
|
||||
* complete at all.
|
||||
*/
|
||||
|
||||
import { applyIntent, areaOf, canAdvanceLoad, destinationsFor, facilityCarTypes, laborersLeft } from '../engine/apply.ts';
|
||||
import {
|
||||
applyIntent,
|
||||
areaOf,
|
||||
canAdvanceLoad,
|
||||
destinationsFor,
|
||||
facilityCarTypes,
|
||||
laborersLeft,
|
||||
ownCutFor,
|
||||
} from '../engine/apply.ts';
|
||||
import { MAX_CONSIST, nextOfficeTier, officeProfile } from '../engine/content.ts';
|
||||
import type { Hand, TrackGeometry } from '../engine/content.ts';
|
||||
import type { CarType, Hand, TrackGeometry } from '../engine/content.ts';
|
||||
import type { GameEvent } from '../engine/events.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import { legalActions } from '../engine/legal.ts';
|
||||
@@ -364,8 +372,10 @@ function chooseLocalOption(
|
||||
return because('the crew is away from the Office and can never depart from where it stands', can('switch')!);
|
||||
}
|
||||
|
||||
// 3. Stock a green box only when the load can actually finish — an empty car of the right type
|
||||
// is already spotted. Stocking without one just fills the box.
|
||||
// 3. Stock a green box when the load can actually be worked — an empty car of the right type is
|
||||
// already spotted. Staging ahead of the car is legal (§6.3) and cannot jam anything, but it
|
||||
// spends the Stage's one Freight Agent action on a load no Laborer can touch yet, so it is not
|
||||
// worth CHOOSING the option for; it is taken only as a fallback once the option is chosen.
|
||||
if (can('freightAgent') && canStockProductively(s, player)) {
|
||||
return because('a green box can be stocked with a load that can actually finish', can('freightAgent')!);
|
||||
}
|
||||
@@ -463,9 +473,13 @@ function isTrainCard(s: GameState, cardId: string): boolean {
|
||||
/**
|
||||
* A facility with room in its green box AND an empty car OF ITS OWN TYPE already spotted.
|
||||
*
|
||||
* The type match matters: a load that reaches WORK with no matching car to go onto is stuck, and a
|
||||
* stuck load strips the industry track of Operational Rail status (§9.3), so no car can be brought
|
||||
* in to rescue it. Stocking speculatively jams the facility.
|
||||
* A PRIORITY TEST, NOT A LEGALITY TEST — the engine stocks either way (§6.3). What it decides is
|
||||
* whether stocking is worth the Local Operations action THIS Stage: with a car spotted the load
|
||||
* moves next Stage, without one it sits.
|
||||
*
|
||||
* The type match matters when it does move: a load that reaches WORK with no matching car to go
|
||||
* onto is stuck, and a stuck load strips the industry track of Operational Rail status (§9.3), so
|
||||
* no car can be brought in to rescue it. `loadCanFinish` keeps the Laborers off such a load.
|
||||
*/
|
||||
function canStockProductively(s: GameState, player: PlayerIndex): boolean {
|
||||
for (const card of areaOf(s, player).grid.values()) {
|
||||
@@ -1036,6 +1050,25 @@ function loadReason(i: Intent): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Would a load stocked here be workable straight away — is there a spotted empty car of that type
|
||||
* not already promised to a load in the box or on the sign?
|
||||
*
|
||||
* A PREFERENCE, NOT A RULE. §6.3 stocking has no car requirement; this only ranks one legal stock
|
||||
* above another, so the Stage's single Freight Agent action goes where a Laborer can use it.
|
||||
*/
|
||||
function stockCanBeWorked(s: GameState, player: PlayerIndex, at: GridCoord, carType: CarType): boolean {
|
||||
const f = areaOf(s, player).grid.get(coordKey(at))?.facility;
|
||||
if (!f) return false;
|
||||
// Passengers board off the platform with no car spotted at all (§9.2), so a stocked coach is
|
||||
// always immediately workable.
|
||||
if (f.kind !== 'freight') return true;
|
||||
const spotted = f.industryTrack.cars.filter((c) => !c.loaded && c.type === carType).length;
|
||||
const staged = f.outboundBox.filter((c) => c.type === carType).length;
|
||||
const working = (f.menAtWork ?? []).filter((l) => l?.dir === 'out' && l.type === carType).length;
|
||||
return spotted - staged - working > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is there already an empty car of the right type spotted to receive this load (§9.3)? Without one
|
||||
* the load can be started and walked across MEN|AT|WORK but can never come off.
|
||||
@@ -1077,10 +1110,30 @@ function consistAfterMove(
|
||||
(d) => d.coord.row === move.to.row && d.coord.col === move.to.col,
|
||||
);
|
||||
if (!dest) return null;
|
||||
// Forward means the engine leads and meets the cars head-on (§A.3).
|
||||
return move.reverse ? [...tray.consist, ...dest.couples] : [...dest.couples, ...tray.consist];
|
||||
/**
|
||||
* Forward means the engine leads and meets the cars head-on (§A.3), and `couples` is ordered
|
||||
* NEAREST FIRST — so the last car met ends up nose-most and the cut reverses on its way into the
|
||||
* tray. Predicting it unreversed made the bot's run-around test read the wrong car as the tail
|
||||
* whenever it approached a cut of two or more forwards.
|
||||
*/
|
||||
return move.reverse
|
||||
? [...tray.consist, ...dest.couples]
|
||||
: [...[...dest.couples].reverse(), ...tray.consist];
|
||||
}
|
||||
|
||||
/**
|
||||
* Would this move drag the cars the crew has just set out back onto the train?
|
||||
*
|
||||
* Coupling is mandatory, so pulling out through the end a cut is standing at picks it up again —
|
||||
* which usually undoes the set-out the crew made this turn. The bot cannot make an ILLEGAL move
|
||||
* (legality is enforced upstream), so this costs it nothing to ignore; it simply wastes the Move,
|
||||
* and the crew arrives with the car it was trying to leave behind.
|
||||
*/
|
||||
function recouplesOwnCut(s: GameState, player: PlayerIndex, i: Intent): boolean {
|
||||
return i.type === 'switch.move' && ownCutFor(s, player, i.trayId, i.reverse).length > 0;
|
||||
}
|
||||
|
||||
|
||||
/** Is this player's crew sitting somewhere it can never depart from? */
|
||||
function strandedFromOffice(s: GameState, player: PlayerIndex): boolean {
|
||||
const area = areaOf(s, player);
|
||||
@@ -1238,6 +1291,26 @@ function followThrough(
|
||||
}
|
||||
|
||||
case 'switch': {
|
||||
/**
|
||||
* A MOVE THAT DRAGS THE CREW'S OWN CUT BACK ON IS A WASTED MOVE, so take those off the table
|
||||
* before any heuristic gets to choose one.
|
||||
*
|
||||
* Coupling is mandatory (§A.4), so pulling out through the end a cut is standing at picks it
|
||||
* straight back up — which undoes the set-out the crew just made. Measured before this filter:
|
||||
* **625 of 1,029 set-outs across 60 games were recoupled by the very next move**, 219 of them
|
||||
* cars that had just been spotted at an industry. The bot was not doing anything illegal; it
|
||||
* was spending two Moves to end up where it started, over and over.
|
||||
*
|
||||
* Filtered here rather than at each `find` below because there are six of them and a rule that
|
||||
* has to be remembered at six call sites is a rule that will be forgotten at the seventh. The
|
||||
* originals come back if the filter would leave the crew with no move at all — backed into a
|
||||
* stub behind its own cars, the only way out IS through them, and standing still is worse.
|
||||
*/
|
||||
const clear = options.filter((i) => !recouplesOwnCut(s, player, i));
|
||||
if (options.some((i) => i.type === 'switch.move') && clear.some((i) => i.type === 'switch.move')) {
|
||||
options = clear;
|
||||
}
|
||||
|
||||
// Re-check every Move, not just when choosing the option. Conditions change mid-turn — a car
|
||||
// gets coupled, a siding fills — and once there is nothing left to do the fall-through would
|
||||
// pick an arbitrary legal move and burn the remaining Moves shuttling.
|
||||
@@ -1457,8 +1530,30 @@ function followThrough(
|
||||
}
|
||||
const clear = options.find((i) => i.type === 'freightAgent.clearInbound');
|
||||
if (clear) return because('the red Inbound box is full and blocking further unloading', clear);
|
||||
const stock = options.find((i) => i.type === 'freightAgent.stockOutbound');
|
||||
if (stock) return because('stock a green box so a Laborer has work next Stage', stock);
|
||||
/**
|
||||
* PREFER THE BOX WHOSE LOAD CAN ACTUALLY BE WORKED.
|
||||
*
|
||||
* §6.3 lets the Freight Agent stage a load with no car spotted — cargo may wait on the dock —
|
||||
* so every facility with room and a matching car in the Division Yard is now offered, not just
|
||||
* the ready ones. `canStockProductively` chose the option believing a ready facility existed;
|
||||
* taking the first offer regardless would spend the Stage's one Freight Agent action on some
|
||||
* other industry that cannot move its load for want of a car. Staging ahead is still taken as
|
||||
* a fallback: it is legal, it cannot jam anything, and it saves an action later.
|
||||
*/
|
||||
const stockable = options.filter(
|
||||
(i): i is Extract<Intent, { type: 'freightAgent.stockOutbound' }> =>
|
||||
i.type === 'freightAgent.stockOutbound',
|
||||
);
|
||||
const workable = stockable.find((i) => stockCanBeWorked(s, player, i.at, i.carType));
|
||||
const stock = workable ?? stockable[0];
|
||||
if (stock) {
|
||||
return because(
|
||||
workable
|
||||
? 'stock a green box whose load a Laborer can start next Stage'
|
||||
: 'stage a load ahead of the car — it waits in the green box until one is spotted',
|
||||
stock,
|
||||
);
|
||||
}
|
||||
/**
|
||||
* NEVER UNJAM A HEALTHY BOX.
|
||||
*
|
||||
@@ -1508,7 +1603,7 @@ function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
|
||||
const f = card.facility;
|
||||
if (!f || f.kind !== 'freight') continue;
|
||||
const spotted = f.industryTrack.cars.length;
|
||||
if (spotted >= f.industryTrack.length) continue;
|
||||
if (spotted >= MAX_CONSIST) continue;
|
||||
// Every commodity the industry handles, asked of its profile. A hand-written switch here had
|
||||
// drifted from the sheet: it named two industries that do not exist (`produceShed`,
|
||||
// `oilRefinery`) and omitted three that do (`freightHouse`, `refinery`, `packingSheds`), so
|
||||
@@ -1537,12 +1632,12 @@ function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
|
||||
* Would spotting this car here do any work?
|
||||
*
|
||||
* An OUTBOUND facility needs an EMPTY car of its own commodity to load onto; an INBOUND one needs
|
||||
* a LOADED car of its commodity to unload. Anything else merely consumes a slot on a finite
|
||||
* industry track (§9.3).
|
||||
* a LOADED car of its commodity to unload. Anything else merely consumes room on the industry's
|
||||
* track, which holds four cars like any other card (§9.3).
|
||||
*/
|
||||
function facilityWants(f: Facility, car: RollingStock): boolean {
|
||||
if (f.kind !== 'freight') return false;
|
||||
if (f.industryTrack.cars.length >= f.industryTrack.length) return false;
|
||||
if (f.industryTrack.cars.length >= MAX_CONSIST) return false;
|
||||
if (!facilityCarTypes(f).includes(car.type)) return false;
|
||||
if (!car.loaded && f.allows.outbound) return true;
|
||||
if (car.loaded && f.allows.inbound) return true;
|
||||
|
||||
+43
-15
@@ -15,6 +15,7 @@
|
||||
* panel cannot drift from the rules.
|
||||
*/
|
||||
|
||||
import { MAX_CONSIST } from '../engine/content.ts';
|
||||
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex, RollingStock, TrayId } from '../engine/state.ts';
|
||||
import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
|
||||
@@ -130,14 +131,26 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
where: e.to,
|
||||
text: `CREW moved ${at(e.from)} → ${at(e.to)} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
|
||||
};
|
||||
case 'carsCoupled':
|
||||
case 'carsCoupled': {
|
||||
/**
|
||||
* TAKING YOUR OWN CUT BACK IS NOT THE SAME EVENT AS FINDING CARS ON THE LINE, and the history
|
||||
* read as though it were — "coupled 2 cars" told a player who had just set those very cars out
|
||||
* that the game had silently undone their work. It has not: they were standing at the end the
|
||||
* train pulled out through, and coupling is mandatory (§A.4).
|
||||
*/
|
||||
const own = e.recoupled?.stock.length ?? 0;
|
||||
const found = e.stock.length - own;
|
||||
const parts: string[] = [];
|
||||
if (own > 0) parts.push(`picked its own ${carsLabel(e.recoupled!.stock)} back up off ${at(e.recoupled!.at)} on the way out`);
|
||||
if (found > 0) parts.push(`coupled ${carsLabel(e.stock.slice(own))} standing on the line`);
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text:
|
||||
`Coupled ${e.stock.length} car(s) at ${at(e.at)} ${e.toNose ? 'ONTO THE NOSE' : 'behind the train'}` +
|
||||
`: ${carsLabel(e.stock)}`,
|
||||
` — ${parts.join(', and ')}`,
|
||||
};
|
||||
}
|
||||
case 'consistSorted':
|
||||
return {
|
||||
tone: 'good',
|
||||
@@ -145,10 +158,14 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
text: `SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
|
||||
};
|
||||
case 'carsDropped':
|
||||
// WHICH END. A cut comes off an outer end (§A.3) and the end decides everything that follows:
|
||||
// the train may pull away from cars set out behind it and must couple back up to cars set out
|
||||
// in front. "Dropped 2 cars" left the one fact that matters out of the record.
|
||||
return {
|
||||
tone: 'plain',
|
||||
where: e.at,
|
||||
text: `Dropped ${carsLabel(e.stock)} at ${at(e.at)}`,
|
||||
text:
|
||||
`Set out ${carsLabel(e.stock)} at ${at(e.at)}, off the ${e.fromNose ? 'NOSE — ahead of the engine, so pulling forward will couple them again' : 'TAIL — behind the engine, so it may pull away and leave them'}`,
|
||||
};
|
||||
|
||||
// -- cards
|
||||
@@ -226,6 +243,14 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
tone: 'bad',
|
||||
text: `SECOND SECTION ordered on Train ${e.trainNumber} — an identical train will run right behind it, which forces the Superintendent to rule on a following train (§8.1)`,
|
||||
};
|
||||
case 'extraStarted':
|
||||
return {
|
||||
tone: 'good',
|
||||
text:
|
||||
e.atSeat === null
|
||||
? `EXTRA X${e.trainNumber} started at the ${e.trainNumber % 2 === 0 ? 'Western' : 'Eastern'} Division Point, running ${e.trainNumber % 2 === 0 ? 'east' : 'west'} — odd numbers run west and even run east (§2.3), so its number chose the end`
|
||||
: `EXTRA X${e.trainNumber} started at the Control Point in seat ${e.atSeat}, running ${e.trainNumber % 2 === 0 ? 'east' : 'west'} — an Extra may begin at any Office above a Whistle Post instead of at a Division Point`,
|
||||
};
|
||||
case 'trainMadeUp':
|
||||
return {
|
||||
tone: 'good',
|
||||
@@ -493,37 +518,40 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
||||
where: `${name} ${key}`,
|
||||
why: blockedByBox
|
||||
? 'green box has a load but MEN is occupied'
|
||||
: `no empty ${want} spotted on this industry's track — a load has nowhere to go until a ` +
|
||||
'crew sets one out here (§9.3)',
|
||||
: `a load is staged but no empty ${want} is spotted on this industry's track — it stays ` +
|
||||
'in the green box until a crew sets one out here (§9.3)',
|
||||
severity: blockedByBox ? 'waiting' : 'stuck',
|
||||
});
|
||||
}
|
||||
|
||||
if (f.allows.outbound && f.outboundBox.length === 0) {
|
||||
/**
|
||||
* WHAT TO DO NEXT, in the order you have to do it.
|
||||
* WHAT TO DO NEXT — AND THE TWO STEPS CAN BE DONE IN EITHER ORDER.
|
||||
*
|
||||
* A freight industry cannot be stocked at all until an empty car of its commodity is standing
|
||||
* on its track (§9.3) — so "needs a Freight Agent action" was the second step told to a player
|
||||
* who had not done the first, and the Freight Agent option simply would not be there. Measured
|
||||
* before the rule was enforced, 78% of the stocking moves the menu offered had no car to load
|
||||
* onto.
|
||||
* §6.3 stocking needs only a car in the Division Yard; §9.3's "Load the car" is what needs an
|
||||
* empty car of the commodity standing on the industry's track. So an empty siding is not a
|
||||
* reason to hold the Freight Agent back — it is a second errand to run before the Laborers can
|
||||
* start. Saying "bring a car in FIRST" sent players to do them in a fixed order they are not
|
||||
* bound by, and wasted the Stage's one Freight Agent action.
|
||||
*/
|
||||
const spotted = f.industryTrack.cars.some((c) => !c.loaded && facilityCarTypes(f).includes(c.type));
|
||||
out.push({
|
||||
where: `${name} ${key}`,
|
||||
why: spotted
|
||||
? 'green box empty — nothing to load (needs a Freight Agent action)'
|
||||
: `green box empty, and no empty ${want} is spotted here — bring one in with a crew FIRST, ` +
|
||||
'then the Freight Agent can stage a load onto it (§9.3)',
|
||||
: `green box empty — the Freight Agent can stage a load now, but no empty ${want} is ` +
|
||||
'spotted here, so a crew must set one out before Laborers can work it (§9.3)',
|
||||
severity: 'waiting',
|
||||
});
|
||||
}
|
||||
|
||||
if (f.industryTrack.cars.length >= f.industryTrack.length) {
|
||||
// Four cars is the whole of any track card, industry or not — the same limit that caps a
|
||||
// consist. It is no longer the industry's box count, which is what used to make a one-box
|
||||
// industry report itself full with a single car standing on it.
|
||||
if (f.industryTrack.cars.length >= MAX_CONSIST) {
|
||||
out.push({
|
||||
where: `${name} ${key}`,
|
||||
why: `industry track full (${f.industryTrack.length} cars) — no room to spot another`,
|
||||
why: `industry track full (${MAX_CONSIST} cars) — no room to spot another`,
|
||||
severity: 'stuck',
|
||||
});
|
||||
}
|
||||
|
||||
+18
-9
@@ -204,7 +204,7 @@ export function compress(frames: Frame[]): Packed {
|
||||
const fi = c.facility ? f.facilities.indexOf(c.facility) : -1;
|
||||
// `train` rides whole rather than being interned: it changes almost every frame, so a table
|
||||
// of them would be as long as the frames are and buy nothing.
|
||||
return [ci, wi, c.enhancements, c.tray, c.cars, fi, c.train, c.ad, c.enhancementsWhat];
|
||||
return [ci, wi, c.enhancements, c.tray, c.cars, fi, c.train, c.ad, c.enhancementsWhat, c.standingWest];
|
||||
});
|
||||
return { ...f, cells } as unknown as Frame;
|
||||
});
|
||||
@@ -238,7 +238,7 @@ export function rehydrateCells(
|
||||
facs: unknown[],
|
||||
): unknown[] {
|
||||
return packed.map((row) => {
|
||||
const p = row as [number, number, string[], string | null, string[], number, unknown, unknown, string[]];
|
||||
const p = row as [number, number, string[], string | null, string[], number, unknown, unknown, string[], number];
|
||||
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
|
||||
return {
|
||||
row: c[0], col: c[1], kind: c[2], label: c[3], running: c[4], links: c[5],
|
||||
@@ -249,6 +249,10 @@ export function rehydrateCells(
|
||||
// Carried rather than recomputed: this function is emitted into the page by toString() and so
|
||||
// cannot reach the card catalogue that produced the text.
|
||||
enhancementsWhat: p[8] ?? [],
|
||||
// Where the train on this card stands among the cars standing on it, so the replay draws a cut
|
||||
// ahead of or behind the engine exactly as the live board does. Absent in older recordings,
|
||||
// which read as 0 — the whole cut east of the engine, which is what they used to draw anyway.
|
||||
standingWest: p[9] ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -453,7 +457,10 @@ const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','
|
||||
function miniFacility(x) {
|
||||
let h = '<div class="mini">';
|
||||
if (x.greenCap > 0) h += '<span class="dim">grn </span>' + boxes(x.green, x.greenCap, 'g');
|
||||
if (x.trackCap > 0) h += '<br><span class="dim">work </span>' +
|
||||
// Gated on the sign itself. This used to gate on trackCap, which was a stand-in for "freight
|
||||
// facility" back when only a freight card had an industry track length; the length is gone and
|
||||
// maw is the honest test — a Passenger Facility has no MEN | AT | WORK sign at all.
|
||||
if (x.maw.length > 0) h += '<br><span class="dim">work </span>' +
|
||||
x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('');
|
||||
if (x.redCap > 0) h += '<br><span class="dim">red </span>' + boxes(x.red, x.redCap, 'r');
|
||||
return h + '</div>';
|
||||
@@ -522,18 +529,20 @@ function render() {
|
||||
'<div class="fac"><b>' + esc(x.name) + '</b> <span class="dim">' + esc(x.commodity) + ' · ' + esc(x.flow) +
|
||||
' · laborers ' + esc(x.laborers) + ' · porters ' + esc(x.porters) + '</span>' +
|
||||
'<div class="boxes"><span class="dim">green</span>' + boxes(x.green, x.greenCap, 'g') + '</div>' +
|
||||
// No sign and no siding on a Passenger Facility: maw is empty for one, but the LABEL was
|
||||
// printed before the loop, so a Depot showed the caption over nothing. The mini card above
|
||||
// gates on trackCap and was already right; this copy was not.
|
||||
// No sign on a Passenger Facility: maw is empty for one, but the LABEL was printed before
|
||||
// the loop, so a Depot showed the caption over nothing.
|
||||
// (No backticks in here — this whole block is inside a template literal.)
|
||||
(x.maw.length > 0
|
||||
? '<div class="boxes"><span class="dim">MEN|AT|WORK</span>' +
|
||||
x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('') + '</div>'
|
||||
: '') +
|
||||
'<div class="boxes"><span class="dim">red</span>' + boxes(x.red, x.redCap, 'r') + '</div>' +
|
||||
// 's', not 'g': the siding is a place, not a direction, and green is reserved for outbound.
|
||||
(x.trackCap > 0 || x.track.length > 0
|
||||
? '<div class="boxes"><span class="dim">siding</span>' + boxes(x.track, x.trackCap, 's') + '</div>'
|
||||
// 's', not 'g': standing here is a place, not a direction, and green is reserved for outbound.
|
||||
// Cap 0 so only the cars actually spotted are drawn — an industry card prints no siding, and
|
||||
// the row of empty squares this used to draw claimed both that it did and that the siding was
|
||||
// as long as the industry's box count. It holds four cars, the same as any track card.
|
||||
(x.track.length > 0
|
||||
? '<div class="boxes"><span class="dim">spotted</span>' + boxes(x.track, 0, 's') + '</div>'
|
||||
: '') +
|
||||
'<div class="fstat ' + (x.jammed ? 'bad' : (x.canFinish ? 'good' : 'idle')) + '">' +
|
||||
(x.jammed
|
||||
|
||||
+82
-9
@@ -17,6 +17,7 @@ import {
|
||||
facilityCarType,
|
||||
laborersLeft,
|
||||
movesFor,
|
||||
ownCutFor,
|
||||
portersLeft,
|
||||
} from '../engine/apply.ts';
|
||||
import {
|
||||
@@ -39,10 +40,11 @@ import {
|
||||
officeProfile,
|
||||
trainProfile,
|
||||
houseRules,
|
||||
mainlineDescription,
|
||||
} from '../engine/content.ts';
|
||||
import type { Intent } from '../engine/intents.ts';
|
||||
import type { Facility, GameState, PlayerIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
||||
import { playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
|
||||
import { carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
|
||||
import type { Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
|
||||
import type { Port } from '../engine/track.ts';
|
||||
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
|
||||
@@ -103,6 +105,18 @@ export type CellView = {
|
||||
*/
|
||||
ad: { used: number; of: number } | null;
|
||||
cars: string[];
|
||||
/**
|
||||
* Cars in `cars` that stand WEST of the train on this card — 0 when no train is here.
|
||||
*
|
||||
* `cars` runs west to east like the state it comes from, and a train standing on the card sits
|
||||
* somewhere IN that row rather than beside it. Reported from play: "right after dropping my cars I
|
||||
* need to be able to see if those cars are ahead or behind the train" — and the board drew the
|
||||
* whole cut in one left-aligned strip at the bottom of the card, which cannot answer that at all.
|
||||
*
|
||||
* Combined with `train.facing` it is the answer: for an east-facing engine the cars east of it are
|
||||
* the ones ahead, and the split is drawn as a gap the chip sits in.
|
||||
*/
|
||||
standingWest: number;
|
||||
facility: FacilityView | null;
|
||||
/**
|
||||
* The port pairs this card joins, as two-letter codes — 'ew' for the through track, and 'ne',
|
||||
@@ -125,7 +139,8 @@ export type FacilityView = {
|
||||
/**
|
||||
* Freight or passengers — the one thing the renderers could not previously ask.
|
||||
*
|
||||
* Without it they guessed from side effects (`trackCap > 0`, `laborers` starting "0/0"), and one
|
||||
* Without it they guessed from side effects (a non-empty industry track, `laborers` starting
|
||||
* "0/0"), and one
|
||||
* of the three simply did not guess at all, so a Depot drew three MEN | AT | WORK boxes it has no
|
||||
* Laborer to work. Reported from playtesting.
|
||||
*/
|
||||
@@ -138,8 +153,15 @@ export type FacilityView = {
|
||||
maw: (string | null)[];
|
||||
red: string[];
|
||||
redCap: number;
|
||||
/**
|
||||
* Cars standing on the industry's track, and nothing about how many more will fit.
|
||||
*
|
||||
* `trackCap` used to ride alongside this and every renderer drew that many empty squares — a
|
||||
* printed siding, as long as the industry's box count. No industry card prints one, and the
|
||||
* number was wrong anyway: a card holds four cars like any other. Both the field and the squares
|
||||
* are gone; the room left is `spaceOn`'s business, not a drawing's.
|
||||
*/
|
||||
track: string[];
|
||||
trackCap: number;
|
||||
laborers: string;
|
||||
porters: string;
|
||||
/**
|
||||
@@ -258,6 +280,14 @@ export type DivisionView = {
|
||||
* on that grade has no visible meaning.
|
||||
*/
|
||||
gradeUp: string | null;
|
||||
/**
|
||||
* Mainline cards only: what the card does to a train, from `mainlineDescription`.
|
||||
*
|
||||
* "I have no idea the impact Hilly and Uncontrolled Siding have on game play" — and there was
|
||||
* nowhere to find out: the tip carried the card's NAME and its modifiers and nothing about the
|
||||
* crossing time or whether trains may pass.
|
||||
*/
|
||||
what?: string;
|
||||
/** Mainline cards only: how many regions the card is divided into (§2.1 — two). */
|
||||
regions?: number;
|
||||
/** Office nodes only: the Running Track, Limits to Limits, west to east. */
|
||||
@@ -479,7 +509,6 @@ function facilityView(
|
||||
red: f.inboundBox.map(carLabel),
|
||||
redCap: f.capacity.inbound,
|
||||
track: f.industryTrack.cars.map(carLabel),
|
||||
trackCap: f.industryTrack.length,
|
||||
laborers: `${laborersLeft(f)}/${f.laborers}`,
|
||||
porters: `${portersLeft(f)}/${f.porters}`,
|
||||
canFinish: canFinishHere(f),
|
||||
@@ -546,6 +575,21 @@ function baseOf(
|
||||
}
|
||||
|
||||
/** The train standing on a given grid square, drawn as it is seated in the Crew Tray. */
|
||||
/**
|
||||
* Where the train standing on this card sits among the cars standing on it, clamped to the row.
|
||||
*
|
||||
* Zero when there is no train, which is also the right answer for a bare cut: with nobody standing
|
||||
* there, a cut has no near or far side and the whole row simply reads west to east.
|
||||
*/
|
||||
function standingWestOn(s: GameState, key: string, cars: number): number {
|
||||
for (const t of s.trays.values()) {
|
||||
if (t.position.at !== 'grid') continue;
|
||||
if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue;
|
||||
return Math.max(0, Math.min(cars, t.standingWest ?? 0));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function trainOnCard(s: GameState, key: string): CellView['train'] {
|
||||
for (const [id, t] of s.trays) {
|
||||
if (t.position.at !== 'grid') continue;
|
||||
@@ -687,7 +731,20 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
const dest = destinationsFor(s, tray!.position.at === 'grid' ? playerAtSeat(s, tray!.position.seat) : 0, i.trayId, here, i.reverse)
|
||||
.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
|
||||
if (dest && dest.couples.length > 0) {
|
||||
picks = ` — couples ${carsLabel(dest.couples)} on the way${i.reverse ? ' (behind)' : ' (onto the nose)'}`;
|
||||
/**
|
||||
* NAME THE CARS THE CREW SET OUT HERE SEPARATELY. They are at the front of `couples` — the
|
||||
* walk seeds itself with the cut at the end the train pulls out through — and a button
|
||||
* reading "couples 2 cars" over cars the player put down thirty seconds ago is exactly the
|
||||
* surprise this label exists to prevent, running the other way.
|
||||
*/
|
||||
const own = ownCutFor(s, playerAtSeat(s, tray!.position.at === 'grid' ? tray!.position.seat : 0), i.trayId, i.reverse).length;
|
||||
const end = i.reverse ? ' (behind)' : ' (onto the nose)';
|
||||
picks =
|
||||
own > 0
|
||||
? ` — takes your own ${carsLabel(dest.couples.slice(0, own))} back off this card` +
|
||||
(dest.couples.length > own ? `, then couples ${carsLabel(dest.couples.slice(own))} on the way` : '') +
|
||||
end
|
||||
: ` — couples ${carsLabel(dest.couples)} on the way${end}`;
|
||||
}
|
||||
}
|
||||
return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}${picks}`;
|
||||
@@ -768,6 +825,16 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
return `board passengers at ${at(i.at)}`;
|
||||
case 'porter.detrain':
|
||||
return `detrain passengers at ${at(i.at)}`;
|
||||
case 'newTrain.startExtra': {
|
||||
const runs = i.trainNumber % 2 === 0 ? 'east' : 'west';
|
||||
if (i.atSeat === null) {
|
||||
const end = i.trainNumber % 2 === 0 ? 'Western' : 'Eastern';
|
||||
return `start Extra X${i.trainNumber} at the ${end} Division Point — it runs ${runs}, so that is the end it starts from`;
|
||||
}
|
||||
const tier = officeProfile(areaAtSeat(s, i.atSeat).tier).name;
|
||||
return `start Extra X${i.trainNumber} at the ${tier} in seat ${i.atSeat} — a Control Point, so it may begin its ${runs}bound run there instead`;
|
||||
}
|
||||
|
||||
case 'newTrain.placeCar':
|
||||
// carLabel knows a caboose carries the crew, not freight. Formatting it here by hand put
|
||||
// "add loaded caboose" on a button.
|
||||
@@ -815,9 +882,13 @@ export function describeIntent(s: GameState, i: Intent): string {
|
||||
// ABS Signals exists to prevent, but no such collision is implemented — granting clearance is
|
||||
// currently free. Saying otherwise invents a consequence the engine will never deliver.
|
||||
// See implications.md §10 Q13.
|
||||
// THE TRAIN BEING RULED ON GOES ON THE BUTTON, not in the tooltip. `actionButton` splits a
|
||||
// label at the first em-dash and shows only the head, so "ALLOW — Train 7 follows…" left the
|
||||
// one thing the ruling is ABOUT — which train — behind a hover. Reported from play: the
|
||||
// Superintendent could not tell which train he was clearing without pointing at the button.
|
||||
return i.allow
|
||||
? `ALLOW — ${who} follows ${ahead} onto the same Mainline card, closing up behind it`
|
||||
: `HOLD — ${who} waits where it is, losing the Stage but keeping the line clear`;
|
||||
? `ALLOW ${who} to follow ${ahead} — onto the same Mainline card, closing up behind it`
|
||||
: `HOLD ${who} — it waits where it is, losing the Stage but keeping the line clear`;
|
||||
}
|
||||
case 'draw.fromDepartment': {
|
||||
// Naming the card is the whole point of a FACE-UP pile: "Department 2" tells a player nothing,
|
||||
@@ -933,7 +1004,8 @@ export function snapshot(
|
||||
card.geometry.kind === 'office'
|
||||
? { used: area.adOccupancy.length, of: officeProfile(area.tier).adTracks }
|
||||
: null,
|
||||
cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
|
||||
cars: carsOn(card).map(carLabel),
|
||||
standingWest: standingWestOn(s, key, carsOn(card).length),
|
||||
facility: fv,
|
||||
});
|
||||
}
|
||||
@@ -1006,6 +1078,7 @@ export function snapshot(
|
||||
...(n.absSignals ? ['ABS Signals'] : []),
|
||||
],
|
||||
gradeUp: isGrade ? (n.gradeUp ?? 'east') : null,
|
||||
what: mainlineDescription(n.card, n.gradeUp ?? 'east'),
|
||||
};
|
||||
}
|
||||
const oa = areaAtSeat(s, n.seat);
|
||||
@@ -1458,7 +1531,7 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
|
||||
const where = onRunning
|
||||
? ' · ON THE RUNNING TRACK — industries belong on a stub; a car left standing here is hit by the next arrival'
|
||||
: '';
|
||||
return `${flow} ${p.carTypes.join('/')} · spot a matching car on its siding to work a load${progress}${where}`;
|
||||
return `${flow} ${p.carTypes.join('/')} · spot a matching car on its track to work a load${progress}${where}`;
|
||||
}
|
||||
case 'track':
|
||||
switch (g.geometry) {
|
||||
|
||||
+50
-3
@@ -292,8 +292,44 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
|
||||
// The tray the phase is waiting on, so the heading names the train the yard chips will load
|
||||
// rather than whichever tray happened to come first out of the map.
|
||||
const filling = prefix === 'newTrain.' ? trainNeedingCars(game.state) : null;
|
||||
const headed = filling !== null ? (consistTitle(game, filling) ?? title) : title;
|
||||
groups.push({ kind: prefix, title: headed, actions });
|
||||
let headed = filling !== null ? (consistTitle(game, filling) ?? title) : title;
|
||||
|
||||
/**
|
||||
* THE RULING NAMES ITS TRAINS IN THE HEADING TOO.
|
||||
*
|
||||
* "rule on this train" was the only thing said in plain sight about the §8.1 clearance, and
|
||||
* "this train" is exactly the part the Superintendent has to know. The pending decision holds
|
||||
* both trays, so the heading can ask the actual question.
|
||||
*/
|
||||
if (prefix === 'mainline.clearance') {
|
||||
const pending = game.state.clock.pendingDecision;
|
||||
if (pending) {
|
||||
headed =
|
||||
`Superintendent — may ${trainName(game.state, pending.train)} follow ` +
|
||||
`${trainName(game.state, pending.occupiedBy)} onto the same Mainline card?`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A PENDING EXTRA IS ITS OWN QUESTION, and its own heading.
|
||||
*
|
||||
* "Where does this Extra start?" arrived in the same `newTrain.` group as "which car goes on
|
||||
* this train", under whichever heading the group happened to have — and with no tray being
|
||||
* filled there is no train to name, so it read "Making up the train" over a choice about a
|
||||
* different train entirely. Split out, and headed with the Extra's own card: it is about to
|
||||
* run, and what it may carry is the thing the player needs before choosing where to put it.
|
||||
*/
|
||||
const extras = actions.filter((a) => options[a.index]?.type === 'newTrain.startExtra');
|
||||
const rest = actions.filter((a) => options[a.index]?.type !== 'newTrain.startExtra');
|
||||
if (extras.length > 0) {
|
||||
const number = (options[extras[0]!.index] as { trainNumber: number }).trainNumber;
|
||||
groups.push({
|
||||
kind: prefix,
|
||||
title: `${trainCardTitle(number, true) ?? title} — choose where it starts`,
|
||||
actions: extras,
|
||||
});
|
||||
}
|
||||
if (rest.length > 0) groups.push({ kind: prefix, title: headed, actions: rest });
|
||||
}
|
||||
}
|
||||
// Anything the table above does not name still has to be offered — silently dropping a legal
|
||||
@@ -683,7 +719,18 @@ function joinsNote(
|
||||
function consistTitle(game: Game, trayId: string): string | null {
|
||||
const tray = game.state.trays.get(trayId);
|
||||
if (!tray) return null;
|
||||
const p = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
return trainCardTitle(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Making up Extra X22 “Pee-Dee”: its card calls for 1 caboose — Per-diem train…"
|
||||
*
|
||||
* Taken by NUMBER rather than by tray, because a pending Extra has no Crew Tray yet — it is waiting
|
||||
* for the player to say where it starts, and that heading has to name the train just as much as the
|
||||
* one over a train being loaded does.
|
||||
*/
|
||||
function trainCardTitle(number: number, isExtra: boolean): string | null {
|
||||
const p = trainProfile(number, isExtra);
|
||||
if (!p) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
+16
-7
@@ -151,7 +151,7 @@ export function blockedHtml(f: Frame): string {
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** The load pipeline at each industry: green → MEN | AT | WORK → red, and the siding. */
|
||||
/** The load pipeline at each industry: green → MEN | AT | WORK → red, and the cars spotted here. */
|
||||
export function facilitiesHtml(f: Frame): string {
|
||||
/**
|
||||
* One number, with what the card printed and what a Modifier added.
|
||||
@@ -248,12 +248,12 @@ export function facilitiesHtml(f: Frame): string {
|
||||
}">${x.kind === 'passenger' ? 'waiting to board' : 'waiting to load'}</span>${boxes(x.green, x.greenCap, 'g')}</div>`
|
||||
: '') +
|
||||
/**
|
||||
* A PASSENGER FACILITY HAS NO SIGN AND NO SIDING, so neither row is drawn for one.
|
||||
* A PASSENGER FACILITY HAS NO SIGN, so the row is not drawn for one.
|
||||
*
|
||||
* `maw` comes back empty for a Depot, Station or Terminal — the pipeline is a Freight
|
||||
* Facility fitting (§9.2 is Porters, with no MEN | AT | WORK step). The row LABEL printed
|
||||
* ahead of the loop regardless, so a Depot showed the caption and three empty boxes it
|
||||
* has no Laborer to work. Same for the siding: an Office has no industry track at all.
|
||||
* has no Laborer to work.
|
||||
*/
|
||||
(x.maw.length > 0
|
||||
? `<div class="boxes"><span class="dim" data-tip="One physical sign, worked in whichever direction this industry runs. Only one load may sit on each of the three boxes.">MEN AT WORK</span>` +
|
||||
@@ -267,14 +267,23 @@ export function facilitiesHtml(f: Frame): string {
|
||||
: 'Loads that have come off an arriving car and been cleared. They travel car → WORK → AT → MEN, then into a red box — the opposite direction to loading.'
|
||||
}">${x.kind === 'passenger' ? 'arrived' : 'cleared inbound'}</span>${boxes(x.red, x.redCap, 'r')}</div>`
|
||||
: '') +
|
||||
(x.trackCap > 0 || x.track.length > 0
|
||||
? `<div class="boxes"><span class="dim" data-tip="Cars spotted on this industry's track. A load leaves the sign onto one of these, or an arriving load starts from one.">siding</span>${boxes(x.track, x.trackCap, 's')}</div>`
|
||||
/**
|
||||
* THE CARS THAT ARE HERE — NOT A SIDING OF A FIXED LENGTH.
|
||||
*
|
||||
* This drew one empty box per unit of the industry's capacity and labelled the row
|
||||
* "siding", which said two untrue things: that the card prints a siding, and that its
|
||||
* length is the box count. It is neither. An industry track is ordinary Operating Rail
|
||||
* and holds four cars like every other card, so there is no printed length worth
|
||||
* drawing — only what is actually standing there. Passing cap 0 renders the cars alone.
|
||||
*/
|
||||
(x.track.length > 0
|
||||
? `<div class="boxes"><span class="dim" data-tip="Cars standing on this industry's track. A load leaves the sign onto one of these, or an arriving load starts from one. Up to four cars fit here, the same as any Operating track card.">spotted</span>${boxes(x.track, 0, 's')}</div>`
|
||||
: '') +
|
||||
`<div class="fstat ${x.jammed ? 'bad' : x.canFinish ? 'good' : 'idle'}" data-tip="${
|
||||
x.jammed
|
||||
? 'A load is sitting on MEN|AT|WORK with no spotted car to receive it. That locks the industry track, which blocks the very car that would clear it (§9.3).'
|
||||
: x.canFinish
|
||||
? 'A matching empty car is spotted on the siding, so a load worked here can come off onto it.'
|
||||
? 'A matching empty car is spotted on this industry\'s track, so a load worked here can come off onto it.'
|
||||
: 'No matching car is spotted. Starting a load here would park it on WORK and jam the facility.'
|
||||
}">` +
|
||||
(x.jammed ? 'JAMMED' : x.canFinish ? 'ready' : 'no car spotted') +
|
||||
@@ -378,7 +387,7 @@ ul.blocked{margin:0;padding-left:18px}
|
||||
.box{display:inline-block;min-width:22px;text-align:center;border-radius:3px;padding:1px 4px;font-size:10px}
|
||||
.box.empty{background:#242a32;color:#5a6472}
|
||||
/* Green OUT, red IN, matching the board SVG's palette exactly — the panel and the map are the same
|
||||
card seen twice, so they may not disagree about which colour means which way. Grey for the siding,
|
||||
card seen twice, so they may not disagree about which colour means which way. Grey for spotted cars,
|
||||
which is a place rather than a direction. */
|
||||
.box.g{background:#2f6b3d}
|
||||
.box.r{background:#8a4a4a}
|
||||
|
||||
Reference in New Issue
Block a user