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:
+108
-5
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user