v0.4.2 fixed passenger trains not stopping at office. changeed revenue for train completing run - 1 point to every player when train exits division

This commit is contained in:
Jesse
2026-08-13 21:29:52 -04:00
parent a7221dcf20
commit 393a669418
21 changed files with 1930 additions and 1531 deletions
+75 -46
View File
@@ -339,7 +339,7 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
(tray.position.at !== 'grid' ||
where.at !== 'grid' ||
(tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col));
if (moved !== 'expedited' && stillThere) {
if (!tray.departsThisStage && stillThere) {
tray.stopPointClaimed = true;
// The point goes to whoever is SITTING in the district it stopped in.
const owner = tray.position.at === 'grid' ? playerAtSeat(s, tray.position.seat) : 0;
@@ -364,11 +364,6 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
}
}
if (moved === 'needsClearance') return { events, needsInput: true };
// An expedited train may act twice in one Stage: it arrives and departs (Q3).
if (moved === 'expedited') {
const again = moveTrain(s, id, tray, events);
if (again === 'needsClearance') return { events, needsInput: true };
}
s.movedThisPhase.add(id);
}
@@ -376,7 +371,7 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
return { events: [...events, ...enterPhase(s, 'loadUnload')], needsInput: false };
}
type MoveOutcome = 'moved' | 'held' | 'needsClearance' | 'expedited';
type MoveOutcome = 'moved' | 'held' | 'needsClearance';
/**
* §8.2 — why this train is not fit to run, or null if it is.
@@ -565,7 +560,6 @@ function moveTrain(
from: 'the Office',
to: 'the Mainline',
});
awardDeparture(s, seat, tray, events);
return 'moved';
}
@@ -580,8 +574,7 @@ function moveTrain(
*/
if (node.kind === 'divisionPoint') {
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
awardDeparture(s, seat, tray, events);
retireTrain(s, id, tray, events);
retireTrain(s, id, tray, node.side, events);
return 'moved';
}
@@ -663,7 +656,7 @@ function moveTrain(
// The train has run the length of the Division and leaves the game (§2.3).
dest.holding.push(id);
tray.position = { at: 'divisionPoint', side: dest.side };
retireTrain(s, id, tray, events);
retireTrain(s, id, tray, dest.side, events);
return 'moved';
}
@@ -844,9 +837,24 @@ function arriveAtOffice(
expedited: isExpedited(tray),
});
// Q3 — an expedited train departs in the same Stage it arrived, so it is NOT added to
// movedThisPhase and gets a second chance to move before the phase ends.
if (isExpedited(tray)) return 'expedited';
/**
* Q3 — AN EXPEDITED TRAIN DEPARTS THE STAGE IT ARRIVED, BUT NOT BEFORE THE WORK IS DONE.
*
* It used to leave inside this very Mainline Phase — a second `moveTrain` on the spot. That made
* "departs the Stage it arrives" true and the coaches worthless: Load/Unload runs AFTER Mainline,
* so an expedited train was gone before a single Porter could reach it. Measured over 60 games,
* Train 2 arrived 32 times and stood at an Office during Load/Unload exactly none of them, and
* every coach-carrying express is expedited (1/2 Crack Limited, 5/6 The Sparrow, 19 Military).
* The Crack Limited's own card says "stop at Terminals only", which it could never do.
*
* So it stands here through Load/Unload and leaves at the end of the Stage instead. It still does
* not lay over to the next Stage — the keyword is honoured — and it is still subject to every
* §8.1 condition when it goes, so it can be held.
*/
if (isExpedited(tray)) {
tray.departsThisStage = true;
return 'moved';
}
/**
* X17 Campaign Train — the speeches happen at the first Office it reaches. Recorded AFTER the
* expedite test above, so this arrival is the ordinary stop the card pays for and every later one
@@ -907,47 +915,47 @@ function collide(
}
}
/** A completed run frees the crew; Extras go to the Salvage Yard (§2.3, Gap 2c). */
/**
* ONE REVENUE FOR EVERY TRAIN THAT GETS OUT OF YOUR SECTION.
* ONE REVENUE TO EVERY PLAYER WHEN A TRAIN COMPLETES ITS RUN.
*
* Jesse's rule. Unlike freight and passengers it pays for traffic the player does not have to work —
* which is the point: keeping the line clear is the Superintendent's job, and until now nothing paid
* for doing it well. It rewards splitting a Subdivision with a Control Point, holding a following
* train rather than gambling on it, and building the A/D capacity to turn arrivals around.
* Jesse's rule, revised. It used to pay 1 to the Office a train departed — "it left YOUR section" —
* which meant a five-Office railroad paid five times for one train and paid the most to whoever the
* train happened to pass first. It pays once now, when the train runs off the end of the Division,
* and it pays EVERYBODY: getting a train the whole length of the railroad is the shared achievement,
* and every Office it crossed had to clear it.
*
* Paid on DEPARTURE from the Office, once per train. Not paid again when the train later runs off
* the end of the Division and retires: it left YOUR section when it cleared your Office, and the far
* Division Point belongs to whoever is seated there.
* Unlike freight and passengers it pays for traffic no one has to work, which is the point: keeping
* the line moving is the Superintendent's job, and nothing else paid for doing it well. It rewards
* splitting a Subdivision with a Control Point, holding a following train rather than gambling on
* it, and building the A/D capacity to turn arrivals around.
*
* A crew with no train number is a local switching move and not a departure, so it earns nothing.
* A crew with no train number is a local switching move, not a run, so it earns nothing.
*
* PROVISIONAL, and worth watching: measured, this is roughly +5 Revenue a game against a mean of
* 2.7, so it more than doubles the score on its own. Flagged in `TODO.md`.
* PROVISIONAL — the previous version was worth about +5 Revenue a game against a mean of 2.7, and
* this one pays far less often. Flagged in `TODO.md`.
*/
function awardDeparture(
s: GameState,
seat: SeatIndex,
tray: CrewTray,
events: GameEvent[],
): void {
function awardCompletedRun(s: GameState, tray: CrewTray, events: GameEvent[]): void {
if (tray.trainNumber === null) return;
// Paid to whoever OCCUPIES the Office the train left, not to the seat's index. Identical today,
// and the difference is the whole point of the seat/player split.
const owner = playerAtSeat(s, seat);
const p = s.players[owner];
if (!p) return;
p.revenue += 1;
events.push({
type: 'revenueChanged',
player: owner,
delta: 1,
total: p.revenue,
reason: 'a train cleared your section',
});
for (const p of s.players) {
p.revenue += 1;
events.push({
type: 'revenueChanged',
player: p.index,
delta: 1,
total: p.revenue,
reason: 'a train completed its run',
});
}
}
function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]): void {
/** A completed run frees the crew; Extras go to the Salvage Yard (§2.3, Gap 2c). */
function retireTrain(
s: GameState,
id: TrayId,
tray: CrewTray,
side: Direction,
events: GameEvent[],
): void {
for (const car of tray.consist) {
if (car.type === 'caboose') s.yards.divisionYard.push(car);
else s.yards.classificationYard.push(car);
@@ -961,8 +969,11 @@ function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent
events.push({
type: 'trainCompleted',
trainNumber: tray.trainNumber ?? 0,
isExtra: tray.trainIsExtra,
side,
consist: tray.consist.map((c) => ({ ...c })),
});
awardCompletedRun(s, tray, events);
}
// ---------------------------------------------------------------------------
@@ -970,6 +981,24 @@ function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent
// ---------------------------------------------------------------------------
function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
/**
* EXPEDITED DEPARTURES — the last thing that happens in a Stage (Q3, and `arriveAtOffice`).
*
* Run here rather than as a phase of its own because there is no decision in it: the trains that
* arrived expedited this Stage try to leave, in train-number order like any other movement, and
* §8.1 may still hold them. Being after Load/Unload is the whole point — it is what gives the
* Porters their one Stage with the train.
*/
const expedited = [...s.trays.entries()]
.filter(([, t]) => t.departsThisStage)
.sort(([, a], [, b]) => (a.trainNumber ?? 0) - (b.trainNumber ?? 0));
for (const [id, tray] of expedited) {
tray.departsThisStage = false;
// A clearance question cannot be asked here — the Superintendent's phase is over — so an
// expedited train that would need a ruling simply stays, and runs normally next Stage.
if (moveTrain(s, id, tray, events) === 'needsClearance') continue;
}
// §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12.
if (s.clock.stage % STAGES_PER_SHIFT === 0) {
s.clock.superintendent = playerLeftOf(s, s.clock.superintendent);