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:
+65
-15
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user