v0.7.2 — a leg that is part of the row, the deck the sheet prints, regions not miles per hour, and a Division you read left to right
Gitea#17 — a 45° leg is an end of the west-to-east row, so backing into a cut through a curve's south leg no longer couples it back to front. The same assumption left a crew's own cut standing when it pulled out through a leg, which is the "cars left behind" report we had failed to reproduce. Gitea#14 — every count is docs/Deck cards5.xlsx. Track halved, and the Q12 office doubling and Gap 12 industry tripling both come out with it: they were measured against a deck with twice the track, and keeping them at the sheet's track count wipes out the reefer chain entirely. 84 rows now match card for card; the ten Safety, Event and Inspection cards it adds are not built and are held out. Cards the sheet no longer lists are dealt zero copies rather than deleted, so their rules stay implemented. Gitea#15 — RAR reversed it: a rail may stop dead against its neighbour and the placement is legal. What must hold is that no train crosses the gap, which was already true and is now pinned against the reported board. Gitea#3 — the printed speeds are scenery. A card costs one Stage per printed region and where a train STARTS is what varies; Fast/Slow is read on Hilly alone. Entering a one-region card behind another is a collision now, which is what ABS exists to prevent, and ABS no longer holds trains silently. Gitea#18 — the Division draws as one row, west to east, with no office-area detail. East is finally always to the right. Closes #3 Closes #14 Closes #15 Closes #17 Closes #18
This commit is contained in:
+168
-28
@@ -23,16 +23,16 @@ import {
|
||||
enhancementRule,
|
||||
crossingStages,
|
||||
trainProfile,
|
||||
startRegion,
|
||||
MOVES_PER_LOCAL_OPS,
|
||||
MOVES_PER_LOCAL_OPS_NIGHT,
|
||||
STAGES_PER_DAY,
|
||||
STAGES_PER_SHIFT,
|
||||
houseRules,
|
||||
officeProfile,
|
||||
REGIONS_PER_MAINLINE_CARD,
|
||||
mainlineProfile,
|
||||
} from './content.ts';
|
||||
import type { Direction } from './content.ts';
|
||||
import type { Direction, MainlineEntry, MainlineKind } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
|
||||
// the legality test cannot disagree about which train is being assembled.
|
||||
@@ -451,6 +451,104 @@ function badlyMadeUp(tray: CrewTray): string | null {
|
||||
return caboose === rear ? null : 'not made up — the caboose must be at the rear of the train';
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH REGION OF A MAINLINE CARD A TRAIN IS STANDING IN (Gitea#3).
|
||||
*
|
||||
* A card is `regions` boxes wide and a train advances one per Stage, so what it has LEFT to run says
|
||||
* where it is: enter with `regions` still to go and you are at the beginning; enter with one to go
|
||||
* and you are in the last box.
|
||||
*
|
||||
* This used to be derived from a single global `REGIONS_PER_MAINLINE_CARD = 2`, with an entry term
|
||||
* that put a one-Stage train in region 1 of a two-region card — a fast train did not traverse a fast
|
||||
* card, it appeared at the far half of it. Cards carry their own region count now, so the position
|
||||
* is simply the count minus what is left.
|
||||
*/
|
||||
export function regionOfTransit(card: MainlineKind, stagesRemaining: number): number {
|
||||
const regions = mainlineProfile(card).regions;
|
||||
return Math.min(regions - 1, Math.max(0, regions - stagesRemaining));
|
||||
}
|
||||
|
||||
/** The entry a train would make onto this card, before occupancy is taken into account. */
|
||||
function entryFor(
|
||||
node: Extract<DivisionNode, { kind: 'mainline' }>,
|
||||
tray: CrewTray,
|
||||
startsAtBack = false,
|
||||
): MainlineEntry {
|
||||
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
return {
|
||||
trainSpeed: profile?.speed ?? 'slow',
|
||||
direction: tray.direction,
|
||||
gradeUp: node.gradeUp ?? 'east',
|
||||
modifiers: node.modifiers ?? [],
|
||||
...(startsAtBack ? { startsAtBack: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* THE UNCONTROLLED SIDING RULE (Gitea#3): "if a train already exists when you arrive, you go in the
|
||||
* second stage back — you are in the siding and are one behind the other train. This prevents a
|
||||
* collision, since you are not in same exact location."
|
||||
*
|
||||
* So arriving at an occupied siding is not a collision and not a hold; it is a different, slower
|
||||
* entry. Anywhere else this returns false and the ordinary start applies.
|
||||
*/
|
||||
function takesTheSiding(node: Extract<DivisionNode, { kind: 'mainline' }>): boolean {
|
||||
return node.card === 'uncontrolledSiding' && node.transits.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* IS MOVING ONTO THIS CARD A COLLISION? (Gitea#3)
|
||||
*
|
||||
* A card can be ONE region wide — Plains, Double Track and Trestle all are — so a following train
|
||||
* granted clearance arrives in the same region as the train ahead the moment it enters. There was no
|
||||
* test for that at all: the catch-up check lives inside `stagesRemaining > 1`, which a one-Stage
|
||||
* crossing never reaches, so entering behind another train on a Plains was silently free.
|
||||
*
|
||||
* ABS is the card that answers it, in RAR's words: "This is played on a mainline card to prevent
|
||||
* collisions. If a collision would normally occur, the train moving onto the card is instead held
|
||||
* back." Held, not waved through — it tries again next Stage.
|
||||
*
|
||||
* The Uncontrolled Siding never conflicts on entry, because `takesTheSiding` has already moved this
|
||||
* train a region back; that is the whole point of the card.
|
||||
*/
|
||||
function entryConflict(
|
||||
s: GameState,
|
||||
node: Extract<DivisionNode, { kind: 'mainline' }>,
|
||||
id: TrayId,
|
||||
tray: CrewTray,
|
||||
events: GameEvent[],
|
||||
startsAtBack = false,
|
||||
): 'collided' | 'held' | null {
|
||||
if (mainlineProfile(node.card).trainsMayPass) return null;
|
||||
const start = startRegion(node.card, entryFor(node, tray, startsAtBack || takesTheSiding(node)));
|
||||
const ahead = node.transits.find(
|
||||
(t) =>
|
||||
t.tray !== id &&
|
||||
t.direction === tray.direction &&
|
||||
regionOfTransit(node.card, t.stagesRemaining) === start,
|
||||
);
|
||||
if (!ahead) return null;
|
||||
|
||||
/**
|
||||
* A BACKSTOP, not the main path. `evaluateClearance` already refuses to clear a train onto a card
|
||||
* carrying ABS, so in the ordinary run of things nothing reaches here with signals up. It stays
|
||||
* because the two rules answer to different questions — clearance looks at the whole Subdivision,
|
||||
* this looks at one region — and a card that promises no rear-enders should not depend on the
|
||||
* wider check happening to fire first.
|
||||
*/
|
||||
if (node.absSignals) {
|
||||
events.push({
|
||||
type: 'trainHeld',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
reason: 'ABS Signals — held short of the train ahead',
|
||||
});
|
||||
return 'held';
|
||||
}
|
||||
// §10 — the Superintendent cleared it into an occupied region, so it is the Superintendent's fault.
|
||||
collide(s, s.clock.superintendent, [id, ahead.tray], events, 'ran into the train ahead', 'the Mainline');
|
||||
return 'collided';
|
||||
}
|
||||
|
||||
/** Puts a train onto a Mainline card with its crossing time already computed. */
|
||||
function enterMainline(
|
||||
s: GameState,
|
||||
@@ -458,17 +556,9 @@ function enterMainline(
|
||||
id: TrayId,
|
||||
tray: CrewTray,
|
||||
index: number,
|
||||
startsAtBack = false,
|
||||
): void {
|
||||
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
const carriesPassengers = tray.consist.some((c) => c.type === 'coach');
|
||||
const stages = crossingStages(
|
||||
node.card,
|
||||
profile?.speed ?? 'slow',
|
||||
carriesPassengers,
|
||||
node.modifiers ?? [],
|
||||
tray.direction,
|
||||
node.gradeUp ?? 'east',
|
||||
);
|
||||
const stages = crossingStages(node.card, entryFor(node, tray, startsAtBack || takesTheSiding(node)));
|
||||
node.transits.push({ tray: id, stagesRemaining: stages, stagesTotal: stages, direction: tray.direction });
|
||||
tray.position = { at: 'mainline', index };
|
||||
// It is running now, so it is no longer being assembled (state.ts). A train at a Division Point
|
||||
@@ -577,6 +667,10 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
||||
if (clearance === 'blocked') return 'held';
|
||||
if (clearance === 'ask') return 'needsClearance';
|
||||
|
||||
const conflict = entryConflict(s, node, id, tray, events);
|
||||
if (conflict === 'held') return 'held';
|
||||
if (conflict === 'collided') return 'moved';
|
||||
|
||||
enterMainline(s, node, id, tray, target);
|
||||
const dp = s.division.nodes[dpIndex];
|
||||
if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id);
|
||||
@@ -636,6 +730,11 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
||||
if (clearance === 'blocked') return 'held';
|
||||
if (clearance === 'ask') return 'needsClearance';
|
||||
|
||||
const conflict = entryConflict(s, node, id, tray, events);
|
||||
if (conflict === 'held') return 'held';
|
||||
// The wreck's A/D track is released by `collide` itself, which is why it has to be.
|
||||
if (conflict === 'collided') return 'moved';
|
||||
|
||||
enterMainline(s, node, id, tray, target);
|
||||
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
|
||||
events.push({
|
||||
@@ -696,8 +795,19 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
||||
}
|
||||
if (clearance === 'ask') return 'needsClearance';
|
||||
|
||||
/**
|
||||
* AN EXTRA PULLING OUT OF THE INTERCHANGE STARTS IN THE BACK REGION (Gitea#3) — "interchange
|
||||
* has new extras show up in second region (like uncontrolled siding)", and earlier, "Plains is
|
||||
* 1 stage for ALL trains. So are interlockings, with a second stage for incoming extras to hold
|
||||
* at." A train running THROUGH the Interchange starts past that region and crosses in one
|
||||
* Stage; one that began its run here has the holding region to clear first.
|
||||
*/
|
||||
const conflict = entryConflict(s, node, id, tray, events, true);
|
||||
if (conflict === 'held') return 'held';
|
||||
if (conflict === 'collided') return 'moved';
|
||||
|
||||
node.holding = node.holding.filter((t) => t !== id);
|
||||
enterMainline(s, node, id, tray, index);
|
||||
enterMainline(s, node, id, tray, index, true);
|
||||
events.push({
|
||||
type: 'trainHighballed',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
@@ -729,21 +839,22 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
||||
*
|
||||
* ABS Signals does what it says instead: the follower stops SHORT of the collision and holds.
|
||||
*/
|
||||
const regionOf = (t: { stagesTotal: number; stagesRemaining: number }): number => {
|
||||
const entry = REGIONS_PER_MAINLINE_CARD - t.stagesTotal;
|
||||
const elapsed = t.stagesTotal - t.stagesRemaining;
|
||||
return Math.min(REGIONS_PER_MAINLINE_CARD - 1, Math.max(0, entry + elapsed));
|
||||
};
|
||||
// NOT on a card that prints "trains may pass". Double Track and Uncontrolled Siding hold two
|
||||
// trains because they HAVE two roads, so a train catching another there goes past it — that
|
||||
// is what the card is for. Without this the mechanic fired 0.41 times a game while the bot
|
||||
// never once granted clearance, which is the tell: those were all passing cards.
|
||||
// NOT on a card that prints "trains may pass" — Double Track holds two trains because it HAS
|
||||
// two roads, so a train catching another there goes past it. That is what the card is for.
|
||||
//
|
||||
// The Uncontrolled Siding used to be in that set and no longer is: it keeps two trains apart
|
||||
// by putting the second one in the siding a region back (`takesTheSiding`), not by letting
|
||||
// them share a place. Marking it "may pass" skipped this test entirely and made the siding do
|
||||
// nothing at all.
|
||||
const mayPass = mainlineProfile(node.card).trainsMayPass;
|
||||
const next = regionOf({ stagesTotal: transit.stagesTotal, stagesRemaining: transit.stagesRemaining - 1 });
|
||||
const next = regionOfTransit(node.card, transit.stagesRemaining - 1);
|
||||
const ahead = mayPass
|
||||
? undefined
|
||||
: node.transits.find(
|
||||
(t) => t.tray !== id && t.direction === transit.direction && regionOf(t) === next,
|
||||
(t) =>
|
||||
t.tray !== id &&
|
||||
t.direction === transit.direction &&
|
||||
regionOfTransit(node.card, t.stagesRemaining) === next,
|
||||
);
|
||||
|
||||
if (ahead) {
|
||||
@@ -882,10 +993,27 @@ function evaluateClearance(
|
||||
// they are not, and the protection belongs where the train it protects actually is.
|
||||
if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked';
|
||||
|
||||
// ABS Signals — "trains on this card will not rear-end each other; they stop short of a
|
||||
// collision". With signals in place a following train simply holds, and the Superintendent has
|
||||
// no judgment call to make. This is the amendment to Gap 2's unconditional collisions.
|
||||
if (onNode?.kind === 'mainline' && onNode.absSignals) return 'blocked';
|
||||
/**
|
||||
* ABS Signals — "this is played on a mainline card to prevent collisions. If a collision would
|
||||
* normally occur, the train moving onto the card is instead held back" (RAR, Gitea#3).
|
||||
*
|
||||
* With signals in place a following train simply holds and the Superintendent has no judgment
|
||||
* call to make, which is the amendment to Gap 2's unconditional collisions. It is caught HERE
|
||||
* rather than at the entry itself, so the train never gets as far as the card.
|
||||
*
|
||||
* IT USED TO HOLD SILENTLY. A blocked clearance emits nothing on the Office and Division Point
|
||||
* paths, so the one card whose entire purpose is to stop a wreck did its job invisibly: the
|
||||
* train simply did not move, Stage after Stage, with nothing on screen saying why. The card is
|
||||
* unplayable to reason about without this line.
|
||||
*/
|
||||
if (onNode?.kind === 'mainline' && onNode.absSignals) {
|
||||
events.push({
|
||||
type: 'trainHeld',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
reason: 'ABS Signals — held short of the train ahead',
|
||||
});
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
// Same direction — the Superintendent must rule (§8.1, fourth condition).
|
||||
s.clock.pendingDecision = { train: id, occupiedBy: other };
|
||||
@@ -1041,6 +1169,18 @@ function collide(
|
||||
if (n.holding) n.holding = n.holding.filter((t) => t !== id);
|
||||
if (n.redFlagged) n.redFlagged = n.redFlagged.filter((t) => t !== id);
|
||||
}
|
||||
/**
|
||||
* AND OFF THE A/D TRACK, for exactly the same reason as the transit above.
|
||||
*
|
||||
* It never mattered while every collision happened to a train already out on the road. Gitea#3
|
||||
* adds one that can happen as a train LEAVES — a following train entering an occupied region —
|
||||
* and that train is still standing at the Office when it dies. Without this its A/D track stays
|
||||
* marked forever: the Office reads as permanently full, and every later arrival collides against
|
||||
* a train that no longer exists.
|
||||
*/
|
||||
for (const [, area] of s.officeAreas) {
|
||||
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
|
||||
}
|
||||
}
|
||||
|
||||
if (lost.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user