v0.4.9 — X,Y coordinates, no-switching can still clear the mainline, Q3 corrected, a clearance bug, three sounds, and the box art
A playtest review of seed 58228926 (day 6), plus one long-standing display complaint and the first real audio beyond a placeholder. - Coordinate labels read X,Y everywhere shown to a player, not the internal Y,X storage order. Display-only. - "No switching" now means may not add or drop cars, not "never touch it" — these trains can still be moved onto Secondary Track to clear the mainline. - Q3 corrected: Expedite governs WHERE a train may be left standing, not WHEN it leaves. The forced same-Stage departure is gone; a new fault costs 1 Revenue if an expedited train is left off the station when a Mainline Phase begins. Resolves "3/4 Express prints a rule it can never use" as a side effect. - evaluateClearance now checks every occupant on a Mainline card before offering a judgment call, instead of returning on whichever it found first — found while explaining a playtest report, fixed with a regression test. - Three new synthesised sounds: arrive, depart, crash. - The splash page shows the box art. Full detail, measurements and reasoning in CHANGELOG.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FvU99NEakShRMg3nN3fHAZ
This commit is contained in:
+76
-89
@@ -18,6 +18,7 @@
|
||||
|
||||
import {
|
||||
COLLISION_PENALTY,
|
||||
EXPEDITE_FAULT_PENALTY,
|
||||
MAINLINE_PROFILES,
|
||||
enhancementRule,
|
||||
crossingStages,
|
||||
@@ -307,6 +308,38 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
return d !== 0 ? d : Number(a.trainIsExtra) - Number(b.trainIsExtra);
|
||||
});
|
||||
|
||||
/**
|
||||
* Q3 — A STATION MASTER FAULT: an expedited train not at the station when a Mainline Phase begins
|
||||
* was not kept ready to highball, wherever in the district it has been left — switched onto
|
||||
* Secondary Track to clear a move, say. Checked against positions as they stand BEFORE this phase
|
||||
* moves anything, and charged every Phase it is still caught away: the fault is in leaving it
|
||||
* there, not a one-time slip. A train the ordinary §8.1 rules are holding at the Office itself is
|
||||
* unaffected — this only bites when the train is not even in the queue to leave.
|
||||
*/
|
||||
for (const [, tray] of order) {
|
||||
if (!isExpedited(tray) || tray.position.at !== 'grid') continue;
|
||||
const area = areaAtSeat(s, tray.position.seat);
|
||||
const { coord } = tray.position;
|
||||
if (coord.row === area.officeCoord.row && coord.col === area.officeCoord.col) continue;
|
||||
const owner = playerAtSeat(s, tray.position.seat);
|
||||
const p = s.players[owner];
|
||||
if (!p) continue;
|
||||
p.revenue -= EXPEDITE_FAULT_PENALTY;
|
||||
events.push({
|
||||
type: 'expediteFault',
|
||||
player: owner,
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
where: `(${coord.col},${coord.row})`,
|
||||
});
|
||||
events.push({
|
||||
type: 'revenueChanged',
|
||||
player: owner,
|
||||
delta: -EXPEDITE_FAULT_PENALTY,
|
||||
total: p.revenue,
|
||||
reason: 'expedited train left off the station',
|
||||
});
|
||||
}
|
||||
|
||||
for (const [id, tray] of order) {
|
||||
if (s.movedThisPhase.has(id)) continue;
|
||||
const where = tray.position;
|
||||
@@ -329,13 +362,13 @@ 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 (!tray.departsThisStage && stillThere) {
|
||||
if (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;
|
||||
const label =
|
||||
tray.position.at === 'grid'
|
||||
? `(${tray.position.coord.row},${tray.position.coord.col})`
|
||||
? `(${tray.position.coord.col},${tray.position.coord.row})`
|
||||
: tray.position.at === 'mainline'
|
||||
? `Mainline card ${tray.position.index}`
|
||||
: `the ${tray.position.side} Division Point`;
|
||||
@@ -475,14 +508,18 @@ function spendDispatchBonus(
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Q3 — an expedited train does not spend a Stage standing at the Office. */
|
||||
/**
|
||||
* Does this train leave in the same Stage it arrives (§7)?
|
||||
* Q3 — is this train subject to the "kept ready" fault (§7)?
|
||||
*
|
||||
* Expedite does not change WHEN a train leaves — it is released by the ordinary §8.1 rules like any
|
||||
* other train, and may be switched normally while it stands. What it means is that it must not be
|
||||
* held anywhere but the station: a train the player parks on Secondary Track to clear a switching
|
||||
* move faults if it is not back on the Office square by the next Mainline Phase (`mainlinePhase`).
|
||||
*
|
||||
* Two ways to earn it. `expedite` is printed and permanent. `stopThenExpedite` is the X17 Campaign
|
||||
* Train — "one turn at station (speeches) then expedite": its FIRST arrival is an ordinary stop while
|
||||
* the speeches are made, and every arrival after that is expedited. `speechMade` is set on that first
|
||||
* stop, so the train is slow once and fast thereafter.
|
||||
* stop, so the train is exempt once and subject to the fault thereafter.
|
||||
*/
|
||||
function isExpedited(tray: CrewTray): boolean {
|
||||
const rules = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules;
|
||||
@@ -491,18 +528,7 @@ function isExpedited(tray: CrewTray): boolean {
|
||||
return rules.stopThenExpedite === true && tray.speechMade === true;
|
||||
}
|
||||
|
||||
function moveTrain(
|
||||
s: GameState,
|
||||
id: TrayId,
|
||||
tray: CrewTray,
|
||||
events: GameEvent[],
|
||||
/**
|
||||
* Which pass is asking. The Mainline Phase moves every train once; Supervisor Shift then asks the
|
||||
* Expedited arrivals to go again. The train cannot tell the difference, but the player must — see
|
||||
* `trainHighballed.why`.
|
||||
*/
|
||||
pass: 'mainline' | 'expedite' = 'mainline',
|
||||
): MoveOutcome {
|
||||
function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]): MoveOutcome {
|
||||
const dir = step(tray.direction);
|
||||
|
||||
if (tray.position.at === 'divisionPoint') {
|
||||
@@ -588,10 +614,7 @@ function moveTrain(
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
from: 'the Office',
|
||||
to: 'the Mainline',
|
||||
why:
|
||||
pass === 'expedite'
|
||||
? 'its card prints EXPEDITE, so it leaves at the end of the Stage it arrived in — you got the Cargo phase with it, but no Local Operations turn to switch it'
|
||||
: 'it stood a full Stage at the Office, so §8.1 released it this Mainline Phase',
|
||||
why: 'it stood a full Stage at the Office, so §8.1 released it this Mainline Phase',
|
||||
});
|
||||
return 'moved';
|
||||
}
|
||||
@@ -751,28 +774,44 @@ function evaluateClearance(
|
||||
for (const t of n.transits) if (t.tray) occupants.push({ tray: t.tray, onCard: i });
|
||||
}
|
||||
|
||||
/**
|
||||
* TWO PASSES, NOT ONE — an opposite-direction occupant is an absolute bar and has to be checked
|
||||
* against EVERY occupant before any same-direction judgment call is offered.
|
||||
*
|
||||
* A single pass returned on whichever occupant it examined first, in `node.transits` insertion
|
||||
* order — which is fine while a Subdivision holds at most one train, but the dispatch exception
|
||||
* below means it now legitimately can hold two: a Telegraph-cleared facing train sits on the same
|
||||
* card as whatever it was cleared past. Found from a playtest report where the Superintendent was
|
||||
* asked to rule on a same-direction train instead of being auto-held against an uncleared
|
||||
* opposite-direction one also on the card — the loop had reached the same-direction occupant
|
||||
* first simply because it entered the transit list first, and returned before ever looking at
|
||||
* the other.
|
||||
*/
|
||||
for (const { tray: other } of occupants) {
|
||||
if (other === id) continue;
|
||||
const otherTray = s.trays.get(other);
|
||||
if (!otherTray || otherTray.direction === tray.direction) continue;
|
||||
// §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) continue;
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
for (const { tray: other, onCard } of occupants) {
|
||||
if (other === id) continue;
|
||||
const otherTray = s.trays.get(other);
|
||||
if (!otherTray) continue;
|
||||
if (!otherTray || otherTray.direction !== tray.direction) continue;
|
||||
const onNode = s.division.nodes[onCard];
|
||||
|
||||
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.
|
||||
//
|
||||
// Both of these now read the card the OTHER train is standing on rather than the card being
|
||||
// Both of these read the card the OTHER train is standing on rather than the card being
|
||||
// entered. They were the same card while this only looked one card ahead; across a Subdivision
|
||||
// they are not, and the protection belongs where the train it protects actually is.
|
||||
if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked';
|
||||
@@ -871,27 +910,9 @@ function arriveAtOffice(
|
||||
});
|
||||
|
||||
/**
|
||||
* 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
|
||||
* runs expedited.
|
||||
* X17 Campaign Train — the speeches happen at the first Office it reaches, and every arrival after
|
||||
* that runs expedited (`isExpedited` reads `speechMade`). Setting it here is idempotent on every
|
||||
* later arrival, so it needs no guard against re-firing.
|
||||
*/
|
||||
if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopThenExpedite) {
|
||||
tray.speechMade = true;
|
||||
@@ -1018,40 +1039,6 @@ function retireTrain(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
/**
|
||||
* An expedited train that would need a §8.1 ruling STAYS, and runs normally from then on.
|
||||
*
|
||||
* Said in the log, because it is the difference between "this train leaves at the end of the
|
||||
* Stage" and "this train is still here next turn", and a player watching one Expedited train
|
||||
* vanish and the next one linger has no way to tell why. Measured on one seed: with ordinary
|
||||
* traffic running, Train 6 was held this way and collected four Local Operations turns.
|
||||
*/
|
||||
if (moveTrain(s, id, tray, events, 'expedite') === 'needsClearance') {
|
||||
events.push({
|
||||
type: 'trainHeld',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
reason:
|
||||
'it EXPEDITES and was due out at the end of this Stage, but the Subdivision ahead needs a ' +
|
||||
'clearance ruling and Supervisor Shift has no Superintendent to give one. It stays, and ' +
|
||||
'runs like an ordinary train from now on — so you DO get a Local Operations turn with it',
|
||||
});
|
||||
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);
|
||||
|
||||
+14
-5
@@ -486,11 +486,14 @@ function freightBudgetLeft(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a train may do switching work at all (§7).
|
||||
* Whether a train may set out or sort cars (§7).
|
||||
*
|
||||
* Six cards print "no switching" — the two expresses, the Light Engine, the Campaign, Circus and
|
||||
* Military trains. They run the Division; they do not shunt. This covers moving, setting out and
|
||||
* sorting alike, because all three are switching.
|
||||
* Military trains — which means they may not ADD or DROP cars, not that they may never be touched: a
|
||||
* train held at the Office may still need to clear onto Secondary Track ahead of other traffic. So
|
||||
* this covers `switch.dropCars` and `switch.sortConsist` only; `switch.move` handles `noSwitching`
|
||||
* itself, the same way it handles `dropOnly`, because the restriction has to bite on the pick-up a
|
||||
* move would make, not on the move itself.
|
||||
*/
|
||||
function switchingRefusal(tray: CrewTray): RejectionCode | null {
|
||||
return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null;
|
||||
@@ -626,8 +629,6 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const noSwitch = switchingRefusal(tray);
|
||||
if (noSwitch) return noSwitch;
|
||||
const from = trayCoord(s, i.trayId);
|
||||
if (!from) return 'ILLEGAL_MOVE';
|
||||
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
|
||||
@@ -652,6 +653,14 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
*/
|
||||
const fresh = dest.couples.slice(ownCutFor(s, player, i.trayId, i.reverse).length);
|
||||
if (fresh.length > 0) {
|
||||
/**
|
||||
* "NO SWITCHING" MEANS NO ADDING OR DROPPING CARS, not "never move". A no-switching train
|
||||
* held at the Office may still need to clear onto Secondary Track ahead of other traffic —
|
||||
* §7 governs coupling, setting out and sorting, and a move that picks nothing up is none of
|
||||
* those. Handled here, alongside `dropOnly`, rather than as a blanket refusal on the intent:
|
||||
* the restriction has to bite on the pick-up itself, the same reasoning as the comment above.
|
||||
*/
|
||||
if (rules.noSwitching) return 'PICKUP_NOT_ALLOWED';
|
||||
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
|
||||
if (rules.pickUpEmptiesOnly && fresh.some((c) => c.loaded)) return 'EMPTIES_ONLY';
|
||||
const freight = fresh.filter(isFreight).length;
|
||||
|
||||
@@ -1004,6 +1004,12 @@ export const MOVES_PER_LOCAL_OPS_NIGHT = 5;
|
||||
export const LABORER_ACTIONS_PER_LOAD = 4;
|
||||
export const COLLISION_PENALTY = 5;
|
||||
export const COLLISION_FLOOR_PER_DAY = 3;
|
||||
/**
|
||||
* Q3 — an expedited train left parked off the Office when a Mainline Phase begins is a Station
|
||||
* Master fault: the train was not kept ready to highball the moment the Subdivision allowed it.
|
||||
* Charged every Phase it is caught there, not just once, since the fault is being left that way.
|
||||
*/
|
||||
export const EXPEDITE_FAULT_PENALTY = 1;
|
||||
|
||||
export function collectiveRevenueFloor(players: number, days: number): number {
|
||||
return 3 * players * days;
|
||||
|
||||
+10
-3
@@ -124,6 +124,13 @@ export type GameEvent =
|
||||
| { type: 'trainHeld'; trainNumber: number; reason: string }
|
||||
/** A train whose card pays for standing still (X18 Circus) collected on it. */
|
||||
| { type: 'trainStoodStill'; trainNumber: number; where: string }
|
||||
/**
|
||||
* Q3 — A STATION MASTER FAULT. An expedited train is not to be held anywhere but the station: if
|
||||
* it is still off the Office square — parked on Secondary Track, say, to clear a switching move —
|
||||
* when a Mainline Phase begins, that is a failure to keep it ready to highball, and it costs
|
||||
* Revenue. `where` names the square it was found on. Always followed by a `revenueChanged`.
|
||||
*/
|
||||
| { type: 'expediteFault'; player: PlayerIndex; trainNumber: number; where: string }
|
||||
/**
|
||||
* `why` is the RULE that let it go now, not a restatement of the move.
|
||||
*
|
||||
@@ -134,9 +141,9 @@ export type GameEvent =
|
||||
*/
|
||||
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string; why: string }
|
||||
/**
|
||||
* `expedited` because it changes what the player must do RIGHT NOW. Q3 — an expedited train
|
||||
* departs the same Stage it arrives, so there is no Local Operations turn in which to work it;
|
||||
* every other train stands until the next Mainline Phase and can be switched in between.
|
||||
* `expedited` flags a train that must not be left standing off the station (Q3) — it may be
|
||||
* switched normally like any other arrival, but it has to be back on the Office square before the
|
||||
* next Mainline Phase begins, or `expediteFault` fires.
|
||||
*/
|
||||
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string; expedited: boolean }
|
||||
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
|
||||
|
||||
+4
-9
@@ -384,17 +384,12 @@ export type CrewTray = {
|
||||
* X17 Campaign Train — "one turn at station (speeches) then expedite".
|
||||
*
|
||||
* It makes its speech at the first Office it reaches: that arrival is an ordinary stop, and from
|
||||
* then on the train runs expedited, departing every Office in the Stage it arrives. Recorded on the
|
||||
* tray for the same reason as `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs
|
||||
* once, so there is no later visit to hang it on.
|
||||
* then on the train is expedited — it may be switched normally, but it faults (Q3) if it is left
|
||||
* off the Office square when a Mainline Phase begins. Recorded on the tray for the same reason as
|
||||
* `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs 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;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user