Implement Enhancements, Mainline modifiers and Maneuvers; fix unplayable track
This commit is contained in:
+114
-2
@@ -19,6 +19,7 @@
|
||||
import {
|
||||
COLLISION_PENALTY,
|
||||
MAINLINE_PROFILES,
|
||||
enhancementRule,
|
||||
consistSize,
|
||||
crossingStages,
|
||||
trainProfile,
|
||||
@@ -349,11 +350,57 @@ function enterMainline(
|
||||
): 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);
|
||||
const stages = crossingStages(
|
||||
node.card,
|
||||
profile?.speed ?? 'slow',
|
||||
carriesPassengers,
|
||||
node.modifiers ?? [],
|
||||
tray.direction,
|
||||
);
|
||||
node.transits.push({ tray: id, stagesRemaining: stages, direction: tray.direction });
|
||||
tray.position = { at: 'mainline', index };
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegraph / Telephone / Radio — "once a day, when dispatching facing trains, add +N to the other
|
||||
* train's number". Spends the best available device the owning player has, once per Day each.
|
||||
*
|
||||
* Returns the bonus applied to the opposing train's number (0 if none was available or needed).
|
||||
*/
|
||||
function spendDispatchBonus(
|
||||
s: GameState,
|
||||
tray: CrewTray,
|
||||
other: CrewTray,
|
||||
events: GameEvent[],
|
||||
): number {
|
||||
const mine = tray.trainNumber ?? 99;
|
||||
const theirs = other.trainNumber ?? 99;
|
||||
|
||||
const area = s.officeAreas.get(s.clock.superintendent);
|
||||
if (!area) return 0;
|
||||
|
||||
// Best device first — Radio (+12) beats Telephone (+8) beats Telegraph (+4).
|
||||
for (const key of ['radio', 'telephone', 'telegraph'] as const) {
|
||||
if (area.dispatchUsedToday.includes(key)) continue;
|
||||
const present = [...area.grid.values()].some((c) => c.enhancements.includes(key));
|
||||
if (!present) continue;
|
||||
const bonus = enhancementRule(key)?.dispatchBonus ?? 0;
|
||||
// Spend it only if it actually wins the meet — the device makes the OTHER train count as
|
||||
// junior, so ours may proceed.
|
||||
if (mine >= theirs + bonus) continue;
|
||||
area.dispatchUsedToday.push(key);
|
||||
events.push({
|
||||
type: 'dispatchBonusUsed',
|
||||
key,
|
||||
bonus,
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
againstTrain: other.trainNumber ?? 0,
|
||||
});
|
||||
return bonus;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Q3 — an expedited train does not spend a Stage standing at the Office. */
|
||||
function isExpedited(tray: CrewTray): boolean {
|
||||
return trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.expedite === true;
|
||||
@@ -449,6 +496,8 @@ function moveTrain(
|
||||
const target = index + dir;
|
||||
const dest = s.division.nodes[target];
|
||||
node.transits = node.transits.filter((t) => t.tray !== id);
|
||||
// Red Flags protect a train while it is stopped here; once it rolls, the flags come in.
|
||||
if (node.redFlagged) node.redFlagged = node.redFlagged.filter((t) => t !== id);
|
||||
|
||||
if (!dest) return 'held';
|
||||
|
||||
@@ -503,7 +552,26 @@ function evaluateClearance(
|
||||
const otherTray = s.trays.get(other);
|
||||
if (!otherTray) continue;
|
||||
|
||||
if (otherTray.direction !== tray.direction) return 'blocked';
|
||||
if (otherTray.direction !== tray.direction) {
|
||||
// §8.1 — a train moving TOWARDS the considered train is an absolute bar. That stands.
|
||||
//
|
||||
// The exception is dispatching technology. Telegraph/Telephone/Radio exist precisely so the
|
||||
// Superintendent can arrange a meet with a facing train: "once a day, when dispatching
|
||||
// facing trains, add +4/+8/+12 to the other train's number". Without a device there is no
|
||||
// way to pass the order, so the train simply holds.
|
||||
if (spendDispatchBonus(s, tray, otherTray, events) > 0) return 'clear';
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
// Red Flags — "a stopped train is prevented from being hit; the approaching train is prevented
|
||||
// from moving". Flagging is per-train rather than per-card, so it protects one specific train
|
||||
// where ABS Signals protects everything on the card.
|
||||
if ((node.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 (node.absSignals) return 'blocked';
|
||||
|
||||
// Same direction — the Superintendent must rule (§8.1, fourth condition).
|
||||
s.clock.pendingDecision = { train: id, occupiedBy: other };
|
||||
@@ -526,13 +594,55 @@ function arriveAtOffice(
|
||||
): MoveOutcome {
|
||||
const area = areaOf(s, owner);
|
||||
const capacity = officeProfile(area.tier).adTracks;
|
||||
const hasEnhancement = (key: string): boolean =>
|
||||
[...area.grid.values()].some((c) => c.enhancements.includes(key));
|
||||
|
||||
// Yard Office — "an inbound train with NO COACHES that can make a single move to the yard office
|
||||
// track may arrive there, not at the Train Order Office". It sidesteps the A/D track entirely.
|
||||
const noCoaches = !tray.consist.some((c) => c.type === 'coach');
|
||||
if (noCoaches && hasEnhancement('yardOffice')) {
|
||||
for (const [key, card] of area.grid) {
|
||||
if (!card.enhancements.includes('yardOffice')) continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
tray.position = { at: 'grid', owner, coord: { row: row!, col: col! } };
|
||||
events.push({
|
||||
type: 'trainDiverted',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
to: 'the Yard Office',
|
||||
reason: 'no coaches, so it need not occupy the Train Order Office',
|
||||
});
|
||||
return 'moved';
|
||||
}
|
||||
}
|
||||
|
||||
// Gap 2d — no room at the station is a collision, and it is the local player's fault (§10).
|
||||
if (area.adOccupancy.length >= capacity) {
|
||||
// Interlocking — "may stop an inbound train on the Limit Track". Instead of an automatic
|
||||
// collision, the train is held at the Limits until an A/D track frees up. This is the designed
|
||||
// answer to the A/D overflow that Gap 2d made fatal.
|
||||
if (hasEnhancement('interlocking')) {
|
||||
area.heldAtLimits.push(id);
|
||||
events.push({
|
||||
type: 'trainDiverted',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
to: 'the Limits',
|
||||
reason: 'Interlocking held it clear of a full Office instead of a collision',
|
||||
});
|
||||
return 'moved';
|
||||
}
|
||||
collide(s, owner, [id], events, 'no free A/D track');
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
// A train held at the Limits takes the first free A/D track before any newcomer.
|
||||
if (area.heldAtLimits.length > 0 && area.heldAtLimits[0] !== id) {
|
||||
const first = area.heldAtLimits.shift()!;
|
||||
area.adOccupancy.push(first);
|
||||
const held = s.trays.get(first);
|
||||
if (held) held.position = { at: 'grid', owner, coord: area.officeCoord };
|
||||
}
|
||||
area.heldAtLimits = area.heldAtLimits.filter((t) => t !== id);
|
||||
|
||||
// §8.3 — cars standing on the Running Track between the Limits and the Office. A train at speed
|
||||
// is not expecting them (§A.4), so this is a collision too, not a coupling.
|
||||
const officeCard = area.grid.get(coordKey(area.officeCoord));
|
||||
@@ -627,6 +737,8 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
}
|
||||
|
||||
if (s.clock.stage >= STAGES_PER_DAY) {
|
||||
// Telegraph/Telephone/Radio are each usable once a Day.
|
||||
for (const area of s.officeAreas.values()) area.dispatchUsedToday = [];
|
||||
s.clock.day += 1;
|
||||
s.clock.stage = 1;
|
||||
s.collisionsToday = 0;
|
||||
|
||||
Reference in New Issue
Block a user