v0.4.4 — New Game dialog for the opening hand and the three revenue rates, Yard renamed Interchange, and two movement bugs behind a mirrored consist

This commit is contained in:
Jesse
2026-08-14 22:39:50 -04:00
parent d1314066bd
commit 6655e20ea8
29 changed files with 5450 additions and 3902 deletions
+65 -15
View File
@@ -25,6 +25,7 @@ import {
industryProfile,
mainlineModifierRule,
mainlineProfile,
houseRules,
modifierProfile,
nextOfficeTier,
officeProfile,
@@ -56,6 +57,7 @@ import {
canDropCarsAt,
canPlaceAt,
carriesThroughTrack,
exitsFrom,
exploreMoves,
facilityVariants,
opposite,
@@ -122,6 +124,20 @@ function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupanc
};
}
/**
* The port a train that came in through `entry` would carry on out by — read off the CARD.
*
* On a straight that is `opposite(entry)`, which is what this used to assume everywhere. On a curve
* it is the other end of the arc, and the two are never the same: a curve joins ADJACENT edges.
*
* A card reached by a Move has exactly one exit from the port it was entered by — the only card with
* three is a turnout, and a train may not finish a Move on one (§A.1). The fallback is for a caller
* holding a card the walk never validated, and matches the old behaviour rather than throwing.
*/
function farPort(card: TrackCard | undefined, entry: Port): Port {
return (card ? exitsFrom(card, entry)[0] : undefined) ?? opposite(entry);
}
/** A tray's facing, expressed as the port it would leave by going forward. */
function facingPort(s: GameState, trayId: TrayId): Port {
const tray = s.trays.get(trayId);
@@ -1183,19 +1199,29 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
to: i.to,
movesRemaining: turnOf(s, player).movesRemaining - 1,
/**
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND.
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND — AND A CURVE IS NOT A STRAIGHT.
*
* `facing` is which way the ENGINE points, and this set it to the direction of travel on
* every move — so one reverse move silently spun the train about. Everything then read
* "forward" again, and a run-around became pointless: you could change ends for free by
* backing up twice.
* `facing` is which way the ENGINE points, and this once set it to the direction of travel
* on every move — so one reverse move silently spun the train about, and a run-around
* became pointless: you could change ends for free by backing up twice.
*
* Running forward the engine leads, so it points the way the train went: `opposite(entry)`.
* Backing up it trails, still pointing the way it came, which is the port it arrived
* through. Both hold around a curve, where the compass heading changes but the engine's
* relationship to its train does not.
* Backing up, the engine TRAILS, still pointing the way it came — out through the port the
* train arrived by. That holds whatever the track does underneath, so it is `dest.entry`
* and nothing else.
*
* Running forward, the engine LEADS, so it points out through the card's far end. That was
* written `opposite(entry)`, which is the far end of a straight and of nothing else: a
* curve is an arc between two ADJACENT edges, so entering a north-west curve through its
* west port leaves the engine facing NORTH, not east. The wrong port was not merely
* cosmetic — `movesFor` explores from `facing`, and a port the card does not have yields
* no destinations at all, so a crew that rounded a curve could only back out the way it
* came. Reported as a consist drawn mirrored, which is the other half of the same bug: the
* east-west sense the board draws is carried from `facing` (`railFacingOf`).
*
* `farPort` asks the CARD. A destination is never a turnout — a train may not finish a
* Move on one (§A.1) — so there is exactly one way out of it.
*/
facing: i.reverse ? dest.entry : opposite(dest.entry),
facing: i.reverse ? dest.entry : farPort(areaOf(s, player).grid.get(coordKey(i.to)), dest.entry),
},
];
if (dest.couples.length > 0) {
@@ -1419,16 +1445,21 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
},
];
/**
* A COACH PAYS AT BOTH ENDS OF ITS JOURNEY — once boarded, once detrained — and each end pays
* `passengerPerCoach` (`content.ts`). Half a passenger movement is half the work, and the rate
* is named per COACH because a Porter handles exactly one coach per action.
*/
case 'porter.board':
return [
{ type: 'passengersBoarded', player, at: i.at },
{ type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'boarding' },
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'boarding'),
];
case 'porter.detrain':
return [
{ type: 'passengersDetrained', player, at: i.at },
{ type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'detraining' },
...earns(s, player, houseRules(s.config).revenue.passengerPerCoach, 'detraining'),
];
case 'laborer.startLoad': {
@@ -1442,18 +1473,20 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
const load = workTrack(f)[i.box]!;
const next = load.dir === 'out' ? i.box + 1 : i.box - 1;
// Like a coach, a load pays at both ends — made up outbound and broken inbound — and each end
// pays `freightPerLoad` (`content.ts`).
if (next >= workTrack(f).length) {
// Outbound complete: the load goes onto the spotted car (§9.3).
return [
{ type: 'loadCompleted', player, at: i.at, carType: load.type },
{ type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'freightLoad' },
...earns(s, player, houseRules(s.config).revenue.freightPerLoad, 'freightLoad'),
];
}
if (next < 0) {
// Inbound complete: the load reaches the red Unloading box (§9.3).
return [
{ type: 'unloadCompleted', player, at: i.at, carType: load.type },
{ type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'freightUnload' },
...earns(s, player, houseRules(s.config).revenue.freightPerLoad, 'freightUnload'),
];
}
return [{ type: 'loadAdvanced', player, at: i.at, fromBox: i.box, toBox: next }];
@@ -1481,6 +1514,18 @@ function revenueAfter(s: GameState, player: PlayerIndex, delta: number): number
return (s.players[player]?.revenue ?? 0) + delta;
}
/**
* A revenue award at this game's rate, or NO EVENT AT ALL when the rate is zero.
*
* Zero is a real setting — it is how you switch one economy off to read the others — and a stream of
* "+0 Revenue" entries in the history panel would be the loudest possible way to say nothing
* happened. The work still happens; it just does not pay.
*/
function earns(s: GameState, player: PlayerIndex, rate: number, reason: string): GameEvent[] {
if (rate <= 0) return [];
return [{ type: 'revenueChanged', player, delta: rate, total: revenueAfter(s, player, rate), reason }];
}
/** §7 — from the rolled slot, walk down the Timetable column, wrapping at the bottom. */
function findTimetableSlot(s: GameState, from: number): number | null {
for (let i = 0; i < s.timetable.length; i++) {
@@ -1509,7 +1554,12 @@ export function reduce(s: GameState, e: GameEvent): void {
// A tray moving stays in the district it was already in — the seat does not change.
const seat = tray.position.at === 'grid' ? tray.position.seat : 0;
tray.position = { at: 'grid', seat, coord: e.to };
if (e.facing) tray.facing = e.facing;
if (e.facing) {
tray.facing = e.facing;
// The east-west sense only exists on east-west track, so it is CARRIED across north-south
// track rather than recomputed there — see `railFacing` in state.ts.
if (e.facing === 'e' || e.facing === 'w') tray.railFacing = e.facing;
}
// Only the player sitting in this district can be switching this tray, so the Moves come off
// their turn. The event carries no player of its own.
turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining;