Track becomes a deck card, Departments become decks, and trains must be made up to leave, industry restrictions enforced, update display and add a new game button

This commit is contained in:
Jesse
2026-08-05 08:28:22 -04:00
parent 2d348e1ecf
commit 9c76b1a5e2
28 changed files with 1927 additions and 394 deletions
+108 -5
View File
@@ -15,7 +15,7 @@ import type {
TrackCard,
TurnoutOrientation,
} from '../src/engine/state.ts';
import { coordKey } from '../src/engine/state.ts';
import { coordKey, isOperationalRail } from '../src/engine/state.ts';
import { createGame } from '../src/engine/setup.ts';
import { applyIntent, areaOf } from '../src/engine/apply.ts';
import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
@@ -34,7 +34,7 @@ import {
slopeOfPair,
variantsFor,
} from '../src/engine/track.ts';
import { TRACK_SUPPLY } from '../src/engine/content.ts';
import { TRACK_CARDS } from '../src/engine/content.ts';
// ---------------------------------------------------------------------------
// Fixture helpers
@@ -142,7 +142,6 @@ function areaFrom(cards: Record<string, TrackCard>, officeCoord: GridCoord): Off
adOccupancy: [],
heldAtLimits: [],
dispatchUsedToday: [],
trackSupply: new Map(),
};
}
@@ -258,7 +257,7 @@ describe('the 45° matching rule', () => {
it('never connects north to south on any card the supply can produce', () => {
// Descending a row therefore costs at least two cards — leg down, then a curve turning the run
// back east-west. A district can never be a vertical column, which is what the sheet shows.
for (const { geometry, hand } of TRACK_SUPPLY) {
for (const { geometry, hand } of TRACK_CARDS) {
for (const v of variantsFor(geometry, hand)) {
const card = { ...straight(), geometry: { kind: 'track' as const, geometry, ...v, ...(hand !== 'none' ? { hand } : {}) } };
for (const [a, b] of connectionsFor(card)) {
@@ -272,7 +271,7 @@ describe('the 45° matching rule', () => {
it('gives every north or south port exactly one slope', () => {
// `slopeAt` returns the first matching pair, so a card carrying two differently-sloped legs at
// one edge would silently answer with whichever came first. Nothing may.
for (const { geometry, hand } of TRACK_SUPPLY) {
for (const { geometry, hand } of TRACK_CARDS) {
for (const v of variantsFor(geometry, hand)) {
const card = { ...straight(), geometry: { kind: 'track' as const, geometry, ...v, ...(hand !== 'none' ? { hand } : {}) } };
for (const p of ['n', 's'] as Port[]) {
@@ -700,3 +699,107 @@ describe('coupling lifts only the cars the crew ran over', () => {
);
});
});
// ---------------------------------------------------------------------------
describe('Operational Rail — where a train may stop and leave cars (Appendix A)', () => {
/**
* "Operational Rail is any track card that a train can stop and leave Rolling Stock (uncouple) on.
* Operational Rail is any track card with a train wheel icon on it."
*
* The wheel icons in the rules diagrams settle which is which: straights, curves, industries, the
* Limits cards and the DEPOT all carry one; the turnout does not. The turnout page says so in
* words too — "Since there is no Operational Rail wheel icon, the train may not stop on this card."
*/
const facilityCard = (menAtWork: (object | null)[]): TrackCard => ({
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
menAtWork: menAtWork as never,
industryTrack: { length: 2, cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
});
it('lets a train stop on a straight, a curve and an industry', () => {
assert.ok(isOperationalRail(straight()));
assert.ok(isOperationalRail(curve('ne')));
assert.ok(isOperationalRail(facilityCard([null, null, null])));
});
it('lets a train stop at the Office, but never leave cars there', () => {
// "While your Office Track is considered Operational Rail, Rolling Stock may not be dropped off
// here. As there is a passenger platform here, it would be dangerous..." The Depot card in the
// rules diagram carries a wheel icon AND is drawn as one of the green squares a Crew Tray may
// move to, so stopping is legal and only uncoupling is not.
const area = areaFrom(
{ [coordKey(at(0, 0))]: officeCard(), [coordKey(at(0, 1))]: straight() },
at(0, 0),
);
assert.ok(isOperationalRail(officeCard()), 'the Office IS Operational Rail');
assert.ok(!canDropCarsAt(area, at(0, 0)), 'but Rolling Stock may not be left there');
assert.ok(canDropCarsAt(area, at(0, 1)), 'ordinary track still accepts cars');
});
it('will not let a train stop on a turnout, or leave cars on one', () => {
// The one card in the switching diagrams with no wheel icon.
const t = turnout({ stem: 'w', through: 'e', diverge: 's' });
assert.ok(!isOperationalRail(t), 'a turnout carries no wheel icon');
const area = areaFrom(
{ [coordKey(at(0, 0))]: straight(), [coordKey(at(0, 1))]: t, [coordKey(at(0, 2))]: straight() },
at(0, 5),
);
assert.ok(!canDropCarsAt(area, at(0, 1)), 'cars may not be left on a turnout');
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
assert.ok(!has(dests, 0, 1), 'a train may not stop on a turnout');
assert.ok(has(dests, 0, 2), 'but it runs straight through one');
});
it('locks an industry down while a load is on MEN | AT | WORK', () => {
// §9.3 — "It loses its status as Operational Rail. No cars can be picked up or dropped off, and
// no trains may occupy OR MOVE ON it." That last clause is stronger than losing Operational Rail
// status, and was missing: a crew rolled straight over a locked industry and coupled the cars
// spotted on it on the way past, which is the pair of things the safety lockout exists to stop.
const locked = facilityCard([{ type: 'hopper', dir: 'out' }, null, null]);
assert.ok(!isOperationalRail(locked));
const area = areaFrom(
{ [coordKey(at(0, 0))]: straight(), [coordKey(at(0, 1))]: locked, [coordKey(at(0, 2))]: straight() },
at(0, 5),
);
assert.ok(!canDropCarsAt(area, at(0, 1)), 'no cars may be dropped off');
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
assert.ok(!has(dests, 0, 1), 'no train may occupy it');
assert.ok(!has(dests, 0, 2), 'and none may move THROUGH it either');
});
it('reopens the industry once the work is cleared', () => {
const area = areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: facilityCard([null, null, null]),
[coordKey(at(0, 2))]: straight(),
},
at(0, 5),
);
const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
assert.ok(has(dests, 0, 1), 'an idle industry is Operational Rail again');
assert.ok(has(dests, 0, 2));
});
it('agrees with the track supply about which pieces carry a wheel', () => {
// The catalogue column and the rules diagrams must not drift apart.
for (const t of TRACK_CARDS) {
assert.equal(
t.isOperationalRail,
t.geometry !== 'turnout',
`${t.name}: only a turnout lacks the wheel icon`,
);
}
});
});