v0.3.0 — playtest fixes, two new rules, and §7 enforced

This commit is contained in:
Jesse
2026-08-12 21:08:28 -04:00
parent 6ead39c530
commit 1bf1e95058
38 changed files with 8514 additions and 5989 deletions
+46 -14
View File
@@ -525,6 +525,13 @@ export function officeSvg(
let bx = 7;
const by = H - 42;
/**
* The MEN | AT | WORK sign is a FREIGHT fitting, so a Passenger Facility has none — `maw` comes
* back empty for one. The arrows below point into and out of that sign, so they go with it:
* without the sign they were chevrons aimed at nothing. A Depot keeps its green and red boxes,
* because passengers really do wait in one and arrive into the other.
*/
const hasSign = f.maw.length > 0;
// Green feeds INTO the sign, red comes OUT of it, so both sit on this side and their arrows
// point opposite ways. An industry that only receives has no green boxes at all.
if (f.allowsOut) {
@@ -532,16 +539,20 @@ export function officeSvg(
out += box(bx, by, i < f.green.length, 'bs-green');
bx += cellW + 2;
}
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
bx += 9;
if (hasSign) {
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">›</text>`;
bx += 9;
}
}
if (f.allowsIn) {
for (let i = 0; i < Math.max(1, f.redCap); i++) {
out += box(bx, by, i < f.red.length, 'bs-red');
bx += cellW + 2;
}
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">‹</text>`;
bx += 9;
if (hasSign) {
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">‹</text>`;
bx += 9;
}
}
// 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.
@@ -549,7 +560,9 @@ export function officeSvg(
out += box(bx, by, f.maw[i] !== null, 'bs-maw', ['M', 'A', 'W'][i]);
bx += cellW + 2;
}
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">${f.allowsOut && f.allowsIn ? '\u21c4' : f.allowsOut ? '›' : '‹'}</text>`;
if (hasSign) {
out += `<text class="bs-arrow" x="${bx + 1}" y="${by + 9}">${f.allowsOut && f.allowsIn ? '\u21c4' : f.allowsOut ? '›' : '‹'}</text>`;
}
/**
* WHICH WAY THIS INDUSTRY RUNS, IN WORDS.
@@ -559,14 +572,21 @@ export function officeSvg(
* playtesting whether the two were supposed to look the same: they were not, and effectively
* they did. A word costs almost nothing and cannot be misread.
*/
const flow = f.allowsOut && f.allowsIn ? 'SHIPS + RECEIVES' : f.allowsOut ? 'SHIPS OUT' : 'RECEIVES';
const flowCls = f.allowsOut && f.allowsIn ? 'bs-flow-both' : f.allowsOut ? 'bs-flow-out' : 'bs-flow-in';
// A Passenger Facility is not an industry and must not borrow an industry's word for it: a
// Depot read "SHIPS + RECEIVES", which says freight. Passengers board and alight instead.
const passenger = f.kind === 'passenger';
const flow = passenger
? 'BOARDS + ALIGHTS'
: f.allowsOut && f.allowsIn ? 'SHIPS + RECEIVES' : f.allowsOut ? 'SHIPS OUT' : 'RECEIVES';
const flowCls = passenger || (f.allowsOut && f.allowsIn) ? 'bs-flow-both' : f.allowsOut ? 'bs-flow-out' : 'bs-flow-in';
out += `<text class="bs-flow ${flowCls}" x="${W - 6}" y="26" text-anchor="end" data-tip="${esc(
f.allowsOut && f.allowsIn
? 'This industry both ships and receives, so it has green boxes AND red ones: loads leave it on a car, and loads arrive on one.'
: f.allowsOut
? 'This industry only SHIPS. A load starts in a green box, is worked along MEN | AT | WORK by Laborers, and comes off onto an empty car spotted on its track. It has no red boxes because nothing arrives here.'
: 'This industry only RECEIVES. A loaded car is spotted on its track and Laborers work the load back along WORK | AT | MEN into a red box. It has no green boxes because nothing ships from here.',
passenger
? 'Passengers only — no freight is handled here. They wait in a green box, are boarded onto a coach by Porters, and arrive into a red one. There is no MEN | AT | WORK sign because that is a Freight Facility fitting.'
: f.allowsOut && f.allowsIn
? 'This industry both ships and receives, so it has green boxes AND red ones: loads leave it on a car, and loads arrive on one.'
: f.allowsOut
? 'This industry only SHIPS. A load starts in a green box, is worked along MEN | AT | WORK by Laborers, and comes off onto an empty car spotted on its track. It has no red boxes because nothing arrives here.'
: 'This industry only RECEIVES. A loaded car is spotted on its track and Laborers work the load back along WORK | AT | MEN into a red box. It has no green boxes because nothing ships from here.',
)}">${esc(flow)}</text>`;
/**
@@ -596,7 +616,15 @@ export function officeSvg(
}
// Standing room, drawn as the printed squares: filled means occupied.
const spots = cell.facility ? Math.max(1, cell.facility.trackCap) : cell.cars.length;
//
// `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
@@ -652,7 +680,11 @@ export function officeSvg(
// ABOVE the rail. An `onCard` enhancement can land on a facility, so it has to clear both the
// load pipeline below and the through track — and with the rail now down the middle of the
// card, the old y = H-46 baseline printed the label straight along the rail itself.
out += `<text class="bs-enh" x="6" y="26">${esc(cell.enhancements.join(' · '))}</text>`;
// Hover text per enhancement, from the card catalogue — an Interlocking used to be a bare word
// on the card with nothing to say what it did, or that it does not do it yet.
out += `<text class="bs-enh" x="6" y="26" data-tip="${esc(
(cell.enhancementsWhat ?? []).join(' · '),
)}">${esc(cell.enhancements.join(' · '))}</text>`;
}
if (cell.train) {
/**
+4 -3
View File
@@ -502,7 +502,7 @@ function canStockProductively(s: GameState, player: PlayerIndex): boolean {
function hasStuckLoad(s: GameState, player: PlayerIndex): boolean {
for (const card of areaOf(s, player).grid.values()) {
const f = card.facility;
if (!f || laborersLeft(f) < 1) continue;
if (!f?.menAtWork || laborersLeft(f) < 1) continue;
for (let box = 0; box < f.menAtWork.length; box++) {
if (f.menAtWork[box] && !canAdvanceLoad(f, box)) return true;
}
@@ -834,12 +834,13 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
};
/**
* A curve of the RIGHT HAND to hang under this turnout, somewhere the player can still reach.
* A curve of the MATCHING hand to hang under this turnout, somewhere the player can still reach.
*
* A turnout is a hole in the Running Track and is worth cutting only for what hangs off it, so
* cutting one with nothing to hang there is pure loss. Handedness is the slope, so a left-hand
* turnout's leg can only be continued by a left-hand curve and a hand full of right-hand ones is
* no help to it.
* no help to it. That is a match on hands, not on a particular hand, so it was unaffected when
* the two were found to be inverted and swapped.
*
* Track is drawn from the deck now rather than taken from a private supply, so "have I got one?"
* means the HAND — with 20 of each hand among 104 track cards in a 243-card deck, the next one is
+2 -2
View File
@@ -460,8 +460,8 @@ export function impediments(s: GameState, player = 0): Impediment[] {
// A load that cannot move, with Laborers standing by, is the worst state a facility reaches:
// it also strips the industry track of Operational Rail status (§9.3), so no car can be
// brought in to rescue it.
for (let box = 0; box < f.menAtWork.length; box++) {
const load = f.menAtWork[box];
for (let box = 0; box < (f.menAtWork?.length ?? 0); box++) {
const load = f.menAtWork![box];
if (!load || canAdvanceLoad(f, box)) continue;
const reason =
laborersLeft(f) < 1
+18 -6
View File
@@ -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];
return [ci, wi, c.enhancements, c.tray, c.cars, fi, c.train, c.ad, c.enhancementsWhat];
});
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];
const p = row as [number, number, string[], string | null, string[], number, unknown, unknown, string[]];
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],
@@ -246,6 +246,9 @@ export function rehydrateCells(
facility: p[5] < 0 ? null : facs[p[5]],
train: p[6] ?? null,
ad: p[7] ?? null,
// 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] ?? [],
};
});
}
@@ -293,7 +296,7 @@ margin-top:5px;font-size:11px;line-height:1.35}
.fac{border:1px solid var(--line);border-radius:5px;padding:8px;margin-bottom:8px;background:#20252c}
.boxes{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px;align-items:center}
.box{border-radius:3px;padding:3px 7px;font-size:11px;border:1px solid var(--line)}
.box.g{background:var(--green)}.box.r{background:var(--red)}.box.m{background:var(--maw)}
.box.g{background:var(--green)}.box.r{background:var(--red)}.box.m{background:var(--maw)}.box.s{background:#3a4450}
.box.empty{background:transparent;color:var(--dim)}
.log{max-height:230px;overflow:auto}
/* Auto-hide, the same as the playable page. Folded leaves a summary rather than nothing, because a
@@ -519,10 +522,19 @@ 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>' +
'<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>' +
// 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 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>' +
'<div class="boxes"><span class="dim">siding</span>' + boxes(x.track, x.trackCap, 'g') + '</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>'
: '') +
'<div class="fstat ' + (x.jammed ? 'bad' : (x.canFinish ? 'good' : 'idle')) + '">' +
(x.jammed
? 'JAMMED — a load is on WORK with no car spotted to receive it; the industry track is locked'
+141 -24
View File
@@ -24,10 +24,13 @@ import {
MAINLINE_MODIFIER_CARDS,
MAINLINE_PROFILES,
MANEUVER_CARDS,
MODIFIER_PROFILES,
REALIGNMENTS,
REGIONS_PER_MAINLINE_CARD,
OFFICE_ORDER,
SPACE_USE_CARDS,
enhancementRule,
enhancementText,
industryProfile,
mainlineProfile,
modifierProfile,
@@ -55,6 +58,13 @@ export type CellView = {
* the district works left no trace you could see.
*/
enhancements: string[];
/**
* What each of those enhancements does, in the same order — and whether it does it yet.
*
* Carried on the view model because `board-svg.ts` imports nothing (the replay embeds it via
* `toString()`), so it cannot reach the card catalogue itself.
*/
enhancementsWhat: string[];
tray: string | null;
/**
* THE TRAIN STANDING HERE, in order, with the engine in it and which way it points.
@@ -108,6 +118,14 @@ export type CellView = {
};
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
* 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.
*/
kind: 'freight' | 'passenger';
name: string;
commodity: string;
flow: string;
@@ -129,6 +147,15 @@ export type FacilityView = {
* anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)".
*/
base: { out: number; in: number; laborers: number; porters: number };
/**
* Printed grants this host's flow throws away, phrased for a tooltip.
*
* An industry's flow is absolute, so an Ice House beside a Grocer's Warehouse gives its Laborer and
* nothing else — the "+1 out" has no direction to go in. Left unsaid that reads as a bug: reported
* from playtesting as "the Ice House added the laborer but not the outbound slot". Naming it turns
* a number that failed to move into a rule the player can see.
*/
suppressed: string[];
/**
* Which way freight actually flows here, so the pipeline can be DRAWN in that direction.
*
@@ -373,6 +400,7 @@ function facilityView(
if (f.kind === 'passenger' && f.porters === 0 && f.capacity.outbound === 0) return null;
const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : '';
return {
kind: f.kind,
name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? prettyKey(key)),
commodity: facilityCarType(f) ?? '?',
flow: f.kind === 'passenger'
@@ -380,7 +408,9 @@ function facilityView(
: f.allows.outbound && f.allows.inbound ? 'both' : f.allows.outbound ? 'ships out' : 'receives',
green: f.outboundBox.map(carLabel),
greenCap: f.capacity.outbound,
maw: f.menAtWork.map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
// Empty on a Passenger Facility, so a renderer that loops it draws nothing without needing a
// guard of its own — which is the whole point of the field being nullable in the engine.
maw: (f.menAtWork ?? []).map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
red: f.inboundBox.map(carLabel),
redCap: f.capacity.inbound,
track: f.industryTrack.cars.map(carLabel),
@@ -388,14 +418,37 @@ function facilityView(
laborers: `${laborersLeft(f)}/${f.laborers}`,
porters: `${portersLeft(f)}/${f.porters}`,
canFinish: canFinishHere(f),
jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
jammed: !!f.menAtWork?.some((l) => l !== null) && !canFinishHere(f),
allowsOut: f.allows.outbound,
allowsIn: f.allows.inbound,
base: baseOf(card, officeName),
suppressed: suppressedGrants(card.modifiers ?? [], f),
modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)),
};
}
/**
* The half of each Modifier beside this facility that its printed flow discards.
*
* Mirrors `usableGrant` in the engine — the engine decides, this only reports. A Grocer's Warehouse
* is `flow: 'inbound'`, so an Ice House's "+1 out" lands nowhere and the panel would otherwise show
* a Modifier that visibly did half of what its card says.
*/
function suppressedGrants(modifiers: string[], f: Facility): string[] {
const out: string[] = [];
for (const key of modifiers) {
const m = MODIFIER_PROFILES.find((p) => p.kind === key);
if (!m) continue;
if (m.addOut > 0 && !f.allows.outbound) {
out.push(`${m.name}: +${m.addOut} outbound has no effect here — this facility only receives`);
}
if (m.addIn > 0 && !f.allows.inbound) {
out.push(`${m.name}: +${m.addIn} inbound has no effect here — this facility only ships`);
}
}
return out;
}
/**
* What the card itself prints, before any Modifier beside it.
*
@@ -446,7 +499,7 @@ function trainOnCard(s: GameState, key: string): CellView['train'] {
/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
function canFinishHere(f: Facility): boolean {
const pending = f.menAtWork.find((l) => l !== null) ?? f.outboundBox[0];
const pending = f.menAtWork?.find((l) => l !== null) ?? f.outboundBox[0];
if (!pending) return false;
return f.industryTrack.cars.some((c) => !c.loaded && c.type === pending.type);
}
@@ -517,7 +570,21 @@ export function describeIntent(s: GameState, i: Intent): string {
i.placement && kind?.kind === 'track'
? variantLabel(kind.geometry, i.variant, kind.hand)
: '';
return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}${turn}`;
/**
* "Play a turnout at (0,1)" and "upgrade the straight at (0,1) to a turnout" are different
* moves — the second lifts a card already down — and calling both "play" hid the fact that the
* square was not empty. The Limits sign is excluded: laying track there is ordinary growth.
*/
const actor = s.clock.currentActor;
const over =
i.placement && actor !== null
? s.officeAreas.get(actor)?.grid.get(`${i.placement.row},${i.placement.col}`)
: undefined;
const upgrade = over?.geometry.kind === 'track';
return (
`${upgrade ? 'upgrade to' : 'play'} ${cardName(s, i.cardId)}` +
`${i.placement ? ` at ${at(i.placement)}` : ''}${turn}`
);
}
case 'card.discard': {
/**
@@ -745,6 +812,7 @@ export function snapshot(
what: cellDescription(card, officeProfile(area.tier).name, row === area.runningRow),
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
enhancements: card.enhancements.map(prettyKey),
enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)),
tray: trayAt.get(key) ?? null,
train: trainOnCard(s, key),
ad:
@@ -895,8 +963,17 @@ export function snapshot(
division,
cells,
facilities,
hand: (s.decks.hands.get(0) ?? []).map((id) => cardName(s, id)),
handWhat: (s.decks.hands.get(0) ?? []).map((id) => cardDescription(s, id)),
/**
* NEWEST FIRST, matching the play page (`actionMenu`).
*
* The engine pushes a drawn card onto the END of the hand, which put the card just turned over
* at the far end of a wrapping row. Reversed here rather than in the engine so the bot's hand
* iteration — and every revenue measurement taken with it — is left alone.
*
* Both lines must reverse together or the descriptions come apart from the names.
*/
hand: [...(s.decks.hands.get(0) ?? [])].reverse().map((id) => cardName(s, id)),
handWhat: [...(s.decks.hands.get(0) ?? [])].reverse().map((id) => cardDescription(s, id)),
deck: s.decks.homeOffice.length,
departments: s.decks.departments.map((pile) => {
const top = pile[pile.length - 1];
@@ -1028,7 +1105,24 @@ export function cardDescription(s: GameState, id: string): string {
if (m.addIn) adds.push(`+${m.addIn} in`);
if (m.addLoaders) adds.push(`+${m.addLoaders} laborer`);
if (m.addPorters) adds.push(`+${m.addPorters} porter`);
return `${adds.join(', ') || 'no change'} · goes beside ${m.hosts.map(facilityLabel).join(' or ')}`;
/**
* WARN BEFORE IT IS PLAYED, not only after.
*
* An industry's printed flow is absolute, so a Modifier granting capacity in the other
* direction gives that host nothing — an Ice House lists both Packing Sheds and a Grocer's
* Warehouse, and only the Packing Sheds can use its "+1 out". Which host you choose is the
* whole decision, so it has to be answerable while the card is still in hand. Computed from
* the two catalogues, so no facility need be on the board yet.
*/
const caveats = m.hosts
.filter((h) => h !== 'office')
.map((h) => ({ host: h, flow: industryProfile(h as never).flow }))
.filter(({ flow }) => (m.addOut > 0 && flow === 'inbound') || (m.addIn > 0 && flow === 'outbound'))
.map(({ host, flow }) => `${facilityLabel(host)} only ${flow === 'inbound' ? 'receives' : 'ships'}`);
const warn = caveats.length
? ` · ${caveats.join(' and ')}, so the ${m.addOut > 0 ? 'outbound' : 'inbound'} slot does nothing there`
: '';
return `${adds.join(', ') || 'no change'} · goes beside ${m.hosts.map(facilityLabel).join(' or ')}${warn}`;
}
case 'track': {
// Track is the largest category in the deck, so a player holds it constantly — and what it
@@ -1038,7 +1132,7 @@ export function cardDescription(s: GameState, id: string): string {
if (k.geometry === 'straight') {
return `east-west through track · lay it anywhere the rail continues${cost}`;
}
const diagonal = k.hand === 'right' ? 'north–west / south–east' : 'north–east / south–west';
const diagonal = k.hand === 'right' ? 'north–east / south–west' : 'north–west / south–east';
const ways = variantsFor(k.geometry, k.hand)
.map((v) => (v.arc ? curvePhrase(v.arc) : v.turnout ? turnoutPhrase(v.turnout) : ''))
.join(', or turned about, ');
@@ -1060,7 +1154,20 @@ export function cardDescription(s: GameState, id: string): string {
default: {
// The recovered categories carry their own prose — effect plus where it may be played.
const card = SIMPLE_CARDS.find((c) => c.key === (k as { key: string }).key);
return card ? `${card.effect} · played on ${card.placement}` : '';
if (!card) return '';
/**
* An Enhancement also says whether its effect is wired up. Four of the ten are read by nothing
* at all, and a card that describes a power it does not have is worse than one that says
* nothing — the player cannot tell a misread from a bug.
*/
const rule = k.kind === 'enhancement' ? enhancementRule(card.key) : null;
const note =
rule?.effect === 'unbuilt'
? ' · NOT YET IMPLEMENTED — no effect in play'
: rule?.effect === 'dormantSolo'
? ' · never fires in solitaire — it answers an opponent card the solo deck omits'
: '';
return `${card.effect} · played on ${card.placement}${note}`;
}
}
}
@@ -1091,18 +1198,28 @@ export function trainRules(t: {
if (p.rules.note) parts.push(p.rules.note);
// Only `expedite` and the consist restrictions are actually enforced today (§7, §8.2), plus the
// Circus Train's point for standing still. The rest are printed on the profile and read nowhere.
const unbuilt: string[] = [];
if (p.rules.noSwitching) unbuilt.push('no switching');
if (p.rules.terminalsOnly) unbuilt.push('terminals only');
if (p.rules.coachStaysOnStationTrack) unbuilt.push('the coach stays on the station track');
if (p.rules.oneFreightPerLocation) unbuilt.push('one freight car per location');
if (p.rules.noPassengerWork) unbuilt.push('no passenger work');
if (p.rules.dropOnly) unbuilt.push('may drop but not pick up');
if (p.rules.pickUpEmptiesOnly) unbuilt.push('may pick up empties only');
if (p.rules.stopThenExpedite) unbuilt.push('stops, then runs expedited');
if (unbuilt.length > 0) parts.push(`NOT YET ENFORCED BY THE ENGINE: ${unbuilt.join('; ')}`);
/**
* §7's operating rules, ALL of which the engine now enforces.
*
* These used to be listed under "NOT YET ENFORCED BY THE ENGINE", which was honest at the time and
* is not any more — every one below is checked in `apply.ts` or `advance.ts`. Saying what a rule
* DOES rather than that it exists, because the restriction is the whole character of the card: a
* Military train that cannot be worked by Porters plays nothing like a Local.
*/
if (p.rules.noSwitching) parts.push('NO SWITCHING — it runs the Division and does not shunt');
if (p.rules.terminalsOnly) parts.push('TERMINALS ONLY — Porters may work it at a Terminal and nowhere else');
if (p.rules.coachStaysOnStationTrack) {
parts.push('THE COACH STAYS AT THE STATION — a cut carrying it may only be set out at the Office');
}
if (p.rules.oneFreightPerLocation) {
parts.push('ONE FREIGHT CAR PER LOCATION — dropped or picked up, one each square per turn');
}
if (p.rules.noPassengerWork) parts.push('NO PASSENGER WORK — Porters may not board or detrain it');
if (p.rules.dropOnly) parts.push('MAY DROP BUT NOT PICK UP — it cannot couple anything');
if (p.rules.pickUpEmptiesOnly) parts.push('EMPTIES ONLY — it may not couple a loaded car');
if (p.rules.stopThenExpedite) {
parts.push('STOPS ONCE FOR SPEECHES, then runs expedited from its next Office onward');
}
if (p.rules.expedite) parts.push('EXPEDITED — it departs in the same Stage it arrives (§7)');
if (p.rules.stopEarnsPoint) parts.push('EARNS A POINT for one Stage spent standing still, once');
@@ -1179,11 +1296,11 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
if (!f) return 'a facility';
if (f.kind === 'passenger') return 'passengers board and detrain here';
// Say where the work has actually got to — the squares on the card show it, this names it.
const inWork = f.menAtWork.findIndex((l) => l !== null);
const inWork = (f.menAtWork ?? []).findIndex((l) => l !== null);
const progress =
inWork >= 0
? ` · a load is on ${['MEN', 'AT', 'WORK'][inWork]}, ${
inWork === f.menAtWork.length - 1
inWork === (f.menAtWork?.length ?? 0) - 1
? 'one more Laborer action and it goes onto a spotted car'
: 'each Laborer action moves it one square right'
}`
@@ -1205,7 +1322,7 @@ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean
return 'through track, east–west';
case 'curved':
case 'sharpCurved': {
const arc = g.arc ?? (g.hand === 'right' ? 'se' : 'sw');
const arc = g.arc ?? (g.hand === 'right' ? 'sw' : 'se');
const cost = g.geometry === 'sharpCurved' ? ' · costs TWO Moves to cross' : '';
return `curve — ${curvePhrase(arc)}${cost}${slopePhrase(arc[0] as Port, arc[1] as Port)}`;
}