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:
+138
-33
@@ -8,7 +8,7 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaOf } from '../src/engine/apply.ts';
|
||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||
import { HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
@@ -794,48 +794,153 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
* EXPEDITE IS CONDITIONAL, which is most of why it feels arbitrary at the table.
|
||||
*
|
||||
* `shiftChange` says so — "an expedited train that would need a ruling simply stays, and runs
|
||||
* normally next Stage" — and with ordinary traffic on the Division it happens a lot: driven by
|
||||
* the developer bot on the same seed, Train 6 was held and collected FOUR Local Operations turns
|
||||
* instead of none. So the log and the card must not promise that an Expedited train can never be
|
||||
* switched; they say "unless the Superintendent has to hold it", and this is why.
|
||||
* normally next Stage" — and it happens often: driven by the developer bot this was reached by
|
||||
* ordinary traffic, and Train 6 collected FOUR Local Operations turns instead of none. Built by
|
||||
* hand here rather than fished out of a bot game, because whether the bot happens to produce a
|
||||
* meet depends on the deck, and the deck moves.
|
||||
*
|
||||
* So the log and the card must not promise that an Expedited train can never be switched.
|
||||
*/
|
||||
const s = game(7, { length: 'standard' });
|
||||
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
|
||||
const id = 'watched';
|
||||
|
||||
// Train 6 (Expedite) standing at the Office, having just arrived: this is the state
|
||||
// `arriveAtOffice` leaves behind when it sets `departsThisStage`.
|
||||
const area = areaOf(s, 0);
|
||||
const id = 'expedited';
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: 6, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'coach', loaded: true }] as never,
|
||||
id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e',
|
||||
position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
departsThisStage: true,
|
||||
});
|
||||
const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === 'west');
|
||||
if (dp?.kind === 'divisionPoint') dp.holding.push(id);
|
||||
area.adOccupancy.push(id);
|
||||
|
||||
let localOps = 0;
|
||||
const seen = new Set<string>();
|
||||
let heldByClearance = false;
|
||||
|
||||
for (let i = 0; i < 20_000; i++) {
|
||||
if (s.trays.get(id)?.position.at === 'grid' && s.clock.phase === 'localOps') {
|
||||
const k = `${s.clock.day}|${s.clock.stage}`;
|
||||
if (!seen.has(k)) { seen.add(k); localOps += 1; }
|
||||
}
|
||||
const r = advance(s);
|
||||
if (r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === id)) heldByClearance = true;
|
||||
if (s.status === 'finished' || s.clock.day > 3) break;
|
||||
if (r.needsInput) {
|
||||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
||||
if (actor === null) break;
|
||||
const options = legalActions(s, actor);
|
||||
if (options.length === 0) break;
|
||||
if (!applyIntent(s, actor, developerBot.choose(s, actor, options)).ok) break;
|
||||
}
|
||||
// A train ahead of it in the same Subdivision, running the SAME way — §8.1's fourth condition,
|
||||
// which is the Superintendent's call rather than an absolute bar.
|
||||
const ahead = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||||
const node = s.division.nodes[ahead];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
s.trays.set('ahead', {
|
||||
id: 'ahead', trainNumber: 12, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e', position: { at: 'mainline', index: ahead }, movesUsed: 0,
|
||||
});
|
||||
if (node?.kind === 'mainline') {
|
||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
}
|
||||
|
||||
assert.ok(heldByClearance, 'the Expedited train was never asked for a clearance ruling');
|
||||
s.clock.phase = 'shiftChange';
|
||||
const r = advance(s);
|
||||
|
||||
assert.ok(
|
||||
localOps > 0,
|
||||
'an Expedited train held for a ruling should get the Local Operations turns it was denied',
|
||||
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === id),
|
||||
'the Expedited departure was not put to the Superintendent',
|
||||
);
|
||||
assert.equal(
|
||||
s.trays.get(id)?.position.at,
|
||||
'grid',
|
||||
'the Expedited train left despite needing a ruling nobody could give',
|
||||
);
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'trainHeld' && /EXPEDITES/.test(e.reason)),
|
||||
'the log does not say why the Expedited train stayed',
|
||||
);
|
||||
assert.equal(
|
||||
s.trays.get(id)?.departsThisStage,
|
||||
false,
|
||||
'it should run as an ordinary train from now on, not retry the expedited departure',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('an Extra starts where its number sends it, or at a Control Point', () => {
|
||||
/**
|
||||
* REPORTED: "Extras should start at Eastern or Western Division point based on their numbers. Even
|
||||
* trains run to the east (start at western DP), odd run to the west (start at eastern DP). They
|
||||
* can also start at a control point (any office except whistlepost) at player's choice."
|
||||
*
|
||||
* Every Extra used to launch eastbound from the West Division Point, hardcoded, with the
|
||||
* simplification flagged in a comment — so half the Extras ran the wrong way and the Control Point
|
||||
* option did not exist at all.
|
||||
*/
|
||||
const pending = (trainNumber: number, tier?: 'depot' | 'station'): GameState => {
|
||||
const s = game(11);
|
||||
s.pendingExtras = [trainNumber];
|
||||
s.timetable = s.timetable.map(() => null);
|
||||
if (tier) s.officeAreas.get(0)!.tier = tier;
|
||||
s.clock.phase = 'newTrain';
|
||||
advance(s);
|
||||
return s;
|
||||
};
|
||||
|
||||
const started = (s: GameState): CrewTray => {
|
||||
const tray = [...s.trays.values()].find((t) => t.trainIsExtra);
|
||||
assert.ok(tray, 'the Extra never took a Crew Tray');
|
||||
return tray;
|
||||
};
|
||||
|
||||
it('stops for the decision instead of launching the train itself', () => {
|
||||
const s = pending(17);
|
||||
assert.equal(s.clock.phase, 'newTrain');
|
||||
assert.equal(s.trays.size, 0, 'the Extra was placed without anyone choosing where');
|
||||
assert.ok(
|
||||
legalActions(s, s.clock.currentActor ?? 0).some((i) => i.type === 'newTrain.startExtra'),
|
||||
'the placement was never offered',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends an odd Extra west from the EASTERN Division Point', () => {
|
||||
// §2.3 — odd runs west. It therefore starts at the end it runs away from.
|
||||
const s = pending(17);
|
||||
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }).ok);
|
||||
const tray = started(s);
|
||||
assert.equal(tray.direction, 'west');
|
||||
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, 'east');
|
||||
});
|
||||
|
||||
it('sends an even Extra east from the WESTERN Division Point', () => {
|
||||
const s = pending(18);
|
||||
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, atSeat: null }).ok);
|
||||
const tray = started(s);
|
||||
assert.equal(tray.direction, 'east');
|
||||
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, 'west');
|
||||
});
|
||||
|
||||
it('refuses a Whistle Post, which is not a Control Point', () => {
|
||||
const s = pending(18);
|
||||
assert.equal(s.officeAreas.get(0)!.tier, 'whistlePost');
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, atSeat: 0 }),
|
||||
'NOT_A_CONTROL_POINT',
|
||||
);
|
||||
});
|
||||
|
||||
it('starts at a Control Point when the player picks one, taking an A/D track', () => {
|
||||
// Upgrading the Office is what buys this: a Depot is a Control Point, a Whistle Post is not.
|
||||
const s = pending(18, 'depot');
|
||||
const r = applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, atSeat: 0 });
|
||||
assert.ok(r.ok, `starting at the Depot was refused: ${r.ok ? '' : r.code}`);
|
||||
const tray = started(s);
|
||||
const area = s.officeAreas.get(0)!;
|
||||
assert.equal(tray.position.at, 'grid');
|
||||
assert.deepEqual(
|
||||
tray.position.at === 'grid' ? tray.position.coord : null,
|
||||
area.officeCoord,
|
||||
'the Extra did not start on the Office card',
|
||||
);
|
||||
assert.ok(area.adOccupancy.includes(tray.id), 'it did not take an A/D track');
|
||||
assert.equal(tray.direction, 'east', 'an even Extra still runs east from wherever it starts');
|
||||
});
|
||||
|
||||
it('takes the Extra off the pending list once, whichever end it started from', () => {
|
||||
const s = pending(17);
|
||||
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }).ok);
|
||||
assert.deepEqual(s.pendingExtras, []);
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }),
|
||||
'NO_EXTRA_PENDING',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user