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);
+47 -10
View File
@@ -457,12 +457,35 @@ function refusesThisOffice(s: GameState, player: PlayerIndex, tray: CrewTray): b
* this works out whether a card is the reason. Without it a Military train standing at the platform
* reported "no train at the Office", which is both wrong and unhelpful.
*/
function passengerRefusal(s: GameState, player: PlayerIndex): RejectionCode {
function passengerRefusal(
s: GameState,
player: PlayerIndex,
at: GridCoord,
dir: 'board' | 'detrain',
): RejectionCode {
const area = areaOf(s, player);
const trains = area.adOccupancy.map((id) => s.trays.get(id)).filter((t): t is CrewTray => !!t);
if (trains.length > 0 && trains.every((t) => refusesThisOffice(s, player, t))) return 'NOT_A_TERMINAL';
if (trains.length > 0 && trains.every(refusesPassengers)) return 'NO_PASSENGER_WORK';
return 'NO_TRAIN_AT_OFFICE';
if (trains.length === 0) return 'NO_TRAIN_AT_OFFICE';
/**
* A TRAIN IS STANDING THERE, so say what is actually missing.
*
* This used to fall through to `NO_TRAIN_AT_OFFICE` — told to a player looking straight at a train
* on their own A/D track, which reads as a broken game rather than a rule. Measured over 60
* solitaire games it fired 51 times with a coach train in front of the player: 27 with nobody
* waiting to travel, 24 with passengers waiting and every coach already full.
*/
const f = facilityAt(s, player, at);
if (!f || f.kind !== 'passenger') return 'NO_SUCH_FACILITY';
if (dir === 'board') {
if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return 'NO_PASSENGERS_WAITING';
return 'NO_EMPTY_COACH';
}
if (f.inboundBox.length >= f.capacity.inbound) return 'INBOUND_BOX_FULL';
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) return 'NO_EMPTY_COACH_IN_YARD';
return 'NO_LOADED_COACH';
}
// ---------------------------------------------------------------------------
@@ -790,17 +813,31 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
}
// -- Load / Unload --------------------------------------------------------
case 'porter.board':
/**
* A WHISTLE POST HAS NO PORTERS AT ALL, which is not the same as having used them.
*
* The Office card carries a passenger facility at every tier so that an upgrade is a property
* change rather than a card swap — but a Whistle Post's has `porters: 0`, so this fell into
* `RESOURCE_SPENT`, "all Porters already used this Stage". A player who had used nothing was
* told they had spent it all, when the answer was to upgrade the Office. 15 times in 60 games.
*/
case 'porter.board': {
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY';
if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT';
return canBoard(s, player, i.at) ? null : passengerRefusal(s, player);
const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY';
if (f.porters < 1) return 'NO_PORTERS_HERE';
if (portersLeft(f) < 1) return 'RESOURCE_SPENT';
return canBoard(s, player, i.at) ? null : passengerRefusal(s, player, i.at, 'board');
}
case 'porter.detrain':
case 'porter.detrain': {
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY';
if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT';
return canDetrain(s, player, i.at) ? null : passengerRefusal(s, player);
const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY';
if (f.porters < 1) return 'NO_PORTERS_HERE';
if (portersLeft(f) < 1) return 'RESOURCE_SPENT';
return canDetrain(s, player, i.at) ? null : passengerRefusal(s, player, i.at, 'detrain');
}
case 'laborer.startLoad': {
if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
+6 -1
View File
@@ -107,7 +107,12 @@ export type GameEvent =
*/
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean }
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
| { type: 'trainCompleted'; trainNumber: number; consist: RollingStock[] }
/**
* The train ran the length of the Division and left it. `side` is the Division Point it left by,
* which is what the announcement needs — and every player scores 1 for it, so this event is
* always followed by one `revenueChanged` per player.
*/
| { type: 'trainCompleted'; trainNumber: number; isExtra: boolean; side: 'east' | 'west'; consist: RollingStock[] }
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
| { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number }
+15 -1
View File
@@ -161,7 +161,21 @@ export type RejectionCode =
| 'NOT_A_GRADE'
| 'TRAIN_ON_CARD'
| 'CONSIST_ORDER'
| 'NO_TRAIN_AT_OFFICE';
| 'NO_TRAIN_AT_OFFICE'
/**
* WHY PASSENGER WORK WAS REFUSED, told apart.
*
* All three used to come back as `NO_TRAIN_AT_OFFICE` — said while a train was standing at the
* Office, which reads as a broken game rather than a rule. Measured over 60 solitaire games, the
* fallback fired 51 times with a coach train in front of the player: 27 with nobody waiting to
* travel, 24 with passengers waiting and every coach on the train already full.
*/
| 'NO_PORTERS_HERE'
| 'NO_PASSENGERS_WAITING'
| 'NO_EMPTY_COACH'
| 'NO_LOADED_COACH'
| 'INBOUND_BOX_FULL'
| 'NO_EMPTY_COACH_IN_YARD';
export type Rejection = { code: RejectionCode; message: string };
+6
View File
@@ -300,6 +300,12 @@ export type CrewTray = {
* once, so there is no later visit to hang it on.
*/
speechMade?: boolean;
/**
* Arrived expedited this Stage, so it departs at the END of the Stage rather than laying over to
* the next one (Q3). Set on arrival; cleared when `shiftChange` attempts the departure — after
* Load/Unload, which is what gives the Porters their one Stage with the train.
*/
departsThisStage?: boolean;
};
// ---------------------------------------------------------------------------