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:
+75
-46
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+71
-26
@@ -269,11 +269,13 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
text: `Train ${e.trainNumber} DIVERTED to ${e.to} — ${e.reason}`,
|
||||
};
|
||||
case 'trainCompleted':
|
||||
// The one line in the log that is good news for everybody, so it is not 'quiet'.
|
||||
return {
|
||||
tone: 'quiet',
|
||||
tone: 'good',
|
||||
text:
|
||||
`Train ${e.trainNumber} finished its run and left the Division carrying ` +
|
||||
`${carsLabel(e.consist)} — the crew is free again`,
|
||||
`Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` +
|
||||
`${e.side === 'east' ? 'Eastern' : 'Western'} Division Point carrying ${carsLabel(e.consist)}. ` +
|
||||
`All players get 1 Revenue. The crew is free again.`,
|
||||
};
|
||||
|
||||
// -- freight agent
|
||||
@@ -533,42 +535,82 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
||||
}
|
||||
|
||||
/**
|
||||
* WHY THE PASSENGERS ARE NOT GETTING OFF.
|
||||
* WHY THE PASSENGERS ARE NOT MOVING — boarding as well as de-training.
|
||||
*
|
||||
* Reported: "I can't figure out how to have a train with three passenger coaches unload all three
|
||||
* at my depot." You cannot — a Depot has ONE Porter and ONE red slot, so it works one coach a
|
||||
* Stage and then needs a Freight Agent action to clear the box before the next. A Station does two
|
||||
* and a Terminal three. That is the Office ladder doing its job, and nothing on screen said so.
|
||||
* Reported first as "I can't figure out how to have a train with three passenger coaches unload
|
||||
* all three at my depot." You cannot — a Depot has ONE Porter and ONE red slot, so it works one
|
||||
* coach a Stage and then needs a Freight Agent action to clear the box before the next. A Station
|
||||
* does two and a Terminal three. That is the Office ladder doing its job, and nothing on screen
|
||||
* said so.
|
||||
*
|
||||
* §9.2 also requires an empty coach in the DIVISION YARD to swap into the train, which is a
|
||||
* requirement the engine only started enforcing recently and which no panel mentions at all.
|
||||
* Reported again as "passenger trains arrive at my Office and move on before I can load or unload
|
||||
* them", which turned out to be three separate silences. This block only looked at trains carrying
|
||||
* a LOADED coach, so a train arriving to PICK UP said nothing at all. A Whistle Post's Office card
|
||||
* carries a passenger facility with zero Porters, so the engine answered `RESOURCE_SPENT` — "all
|
||||
* Porters used" — to a player who had used none. And an expedited train leaves at the end of the
|
||||
* Stage it arrives, so its one Load/Unload phase is now-or-never and nothing said which trains
|
||||
* those are.
|
||||
*
|
||||
* §9.2 also requires an empty coach in the DIVISION YARD to swap into the train, which no panel
|
||||
* mentioned at all.
|
||||
*/
|
||||
const office = area.grid.get(coordKey(area.officeCoord));
|
||||
const pf = office?.facility;
|
||||
if (pf && pf.kind === 'passenger') {
|
||||
const waiting = area.adOccupancy
|
||||
const coachTrains = area.adOccupancy
|
||||
.map((id) => s.trays.get(id))
|
||||
.filter((t) => t?.consist.some((c) => c.type === 'coach' && c.loaded));
|
||||
if (waiting.length > 0) {
|
||||
const reasons: string[] = [];
|
||||
if (portersLeft(pf) < 1) {
|
||||
reasons.push(
|
||||
.filter((t): t is NonNullable<typeof t> => !!t && t.consist.some((c) => c.type === 'coach'));
|
||||
|
||||
if (coachTrains.length > 0) {
|
||||
const label = (t: (typeof coachTrains)[number]): string =>
|
||||
t.trainNumber === null ? 'the local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`;
|
||||
const say = (why: string, severity: Impediment['severity'] = 'waiting'): void => {
|
||||
out.push({ where: 'Office — passengers', why, severity });
|
||||
};
|
||||
|
||||
const boarding = pf.outboundBox.some((c) => c.type === 'coach' && c.loaded);
|
||||
const emptyOnTrain = coachTrains.some((t) => t.consist.some((c) => c.type === 'coach' && !c.loaded));
|
||||
const loadedOnTrain = coachTrains.some((t) => t.consist.some((c) => c.type === 'coach' && c.loaded));
|
||||
|
||||
if (pf.porters < 1) {
|
||||
say(
|
||||
'this Office has NO Porters — a Whistle Post is not a Passenger Facility (§9) and cannot ' +
|
||||
'work passengers at all. Upgrade it to a Depot or better.',
|
||||
'stuck',
|
||||
);
|
||||
} else if (portersLeft(pf) < 1) {
|
||||
say(
|
||||
`all ${pf.porters} Porter${pf.porters === 1 ? '' : 's'} used this Stage — one works one coach, ` +
|
||||
'and the Office tier is the Porter count (Depot 1, Station 2, Terminal 3)',
|
||||
);
|
||||
} else {
|
||||
if (boarding && !emptyOnTrain) {
|
||||
say('passengers are waiting to board, but every coach standing here is already full');
|
||||
}
|
||||
if (!boarding && !loadedOnTrain) {
|
||||
say('nobody waiting to travel and no loaded coach to set down — stock the platform with a Freight Agent action');
|
||||
}
|
||||
if (loadedOnTrain && pf.inboundBox.length >= pf.capacity.inbound) {
|
||||
say(
|
||||
`the red Unloading box is full (${pf.capacity.inbound} slot${pf.capacity.inbound === 1 ? '' : 's'}) — ` +
|
||||
'a Freight Agent action clears it, which costs a whole Local Operations turn',
|
||||
);
|
||||
}
|
||||
if (loadedOnTrain && !s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) {
|
||||
say('no EMPTY coach in the Division Yard to swap into the train (§9.2 requires one)');
|
||||
}
|
||||
}
|
||||
if (pf.inboundBox.length >= pf.capacity.inbound) {
|
||||
reasons.push(
|
||||
`the red Unloading box is full (${pf.capacity.inbound} slot${pf.capacity.inbound === 1 ? '' : 's'}) — ` +
|
||||
'a Freight Agent action clears it, which costs a whole Local Operations turn',
|
||||
|
||||
// NOW OR NEVER. An expedited train leaves at the end of this Stage, so this is the only
|
||||
// Load/Unload phase it will ever stand for — the exact thing the playtest report was about.
|
||||
for (const t of coachTrains) {
|
||||
if (!t.departsThisStage) continue;
|
||||
say(
|
||||
`${label(t)} is EXPEDITED — it leaves at the END of this Stage, so this is the only ` +
|
||||
'Load/Unload phase it will stand for. Work it now or not at all.',
|
||||
'risk',
|
||||
);
|
||||
}
|
||||
if (!s.yards.divisionYard.some((c) => c.type === 'coach' && !c.loaded)) {
|
||||
reasons.push('no EMPTY coach in the Division Yard to swap into the train (§9.2 requires one)');
|
||||
}
|
||||
for (const why of reasons) {
|
||||
out.push({ where: 'passengers waiting to de-train', why, severity: 'waiting' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,6 +687,9 @@ export function cuesFor(events: readonly GameEvent[]): string[] {
|
||||
// The 1D12 that sets a train's departure Stage. It is the one roll a player makes and it landed
|
||||
// silently, so the card was gone and the answer to "when does it run?" was a line of history.
|
||||
if (e.type === 'trainScheduled') out.push('schedule');
|
||||
// A train running the whole length of the Division pays every player, and it is the one event
|
||||
// nobody made happen on the turn it lands. It should be heard, not found in the log.
|
||||
if (e.type === 'trainCompleted') out.push('completed');
|
||||
if (e.type === 'stageBegan') {
|
||||
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening
|
||||
// and has nothing behind it. A Day boundary rings the bell only: sounding both would collide,
|
||||
|
||||
+16
-1
@@ -116,6 +116,12 @@ export type Game = {
|
||||
* clear it when the card is played — no element carries the class once the card leaves the hand.
|
||||
*/
|
||||
justDrawn: CardId | null;
|
||||
/**
|
||||
* A one-line announcement to flash, once. Drained like `scheduled` rather than read like `Frame`,
|
||||
* because it marks a MOMENT — the only event in the game that scores for everybody without anyone
|
||||
* having taken a turn to cause it.
|
||||
*/
|
||||
announced: string | null;
|
||||
};
|
||||
|
||||
/** How each intent kind is introduced in the action list, in the order they should appear. */
|
||||
@@ -150,7 +156,7 @@ export const SOLO_PLAYER = 'Solitaire';
|
||||
|
||||
export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||
const state = createGame({ id: `web-${seed}`, seed, config, playerNames: [SOLO_PLAYER] });
|
||||
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null };
|
||||
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null };
|
||||
// A history that opens mid-Stage reads as though something was missed. Say what the game IS
|
||||
// first, then let the clock take over.
|
||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||
@@ -685,6 +691,15 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
for (const e of events) if (e.type === 'trainScheduled') game.scheduled = e.slot;
|
||||
// Which card just came into hand, so the row can badge it. Last one wins for the same reason.
|
||||
for (const e of events) if (e.type === 'cardDrawn') game.justDrawn = e.cardId;
|
||||
// A train that ran the whole Division pays EVERY player, and nobody did anything on this turn to
|
||||
// make it happen — so it is announced rather than left to be found in the log.
|
||||
for (const e of events) {
|
||||
if (e.type === 'trainCompleted') {
|
||||
game.announced =
|
||||
`Train ${e.isExtra ? 'X' : ''}${e.trainNumber} has completed its run, leaving via the ` +
|
||||
`${e.side === 'east' ? 'Eastern' : 'Western'} Division Point. All players get 1 Revenue.`;
|
||||
}
|
||||
}
|
||||
// Keep the log bounded; the full history lives in `history` and can be replayed.
|
||||
if (game.log.length > 400) game.log.splice(0, game.log.length - 400);
|
||||
}
|
||||
|
||||
@@ -459,6 +459,20 @@ function render(): void {
|
||||
}
|
||||
lastPhase = f.phaseKey;
|
||||
|
||||
/**
|
||||
* A train completing its run pays every player and nobody took a turn to cause it, so it is said
|
||||
* out loud rather than left in the log. Drained, so it shows once and does not re-fire on a redraw.
|
||||
*/
|
||||
const announcement = session.takeAnnouncement();
|
||||
if (announcement) {
|
||||
const el = $('announce');
|
||||
el.textContent = announcement;
|
||||
el.className = 'shown';
|
||||
window.setTimeout(() => {
|
||||
if (el.textContent === announcement) el.className = '';
|
||||
}, 4200);
|
||||
}
|
||||
|
||||
renderDistrict(f);
|
||||
renderActions(menu, f, justSet);
|
||||
renderUndo();
|
||||
|
||||
@@ -88,6 +88,14 @@ button.cardact.discard{color:#d6b48a}
|
||||
box-shadow:0 6px 22px rgba(0,0,0,.5)}
|
||||
#phasenote.shown{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||
@media(prefers-reduced-motion:reduce){#phasenote{transition:none}}
|
||||
/* A train completing its run pays EVERY player, so it is announced rather than left to the log —
|
||||
green like a revenue gain, and its own element so it cannot fight the phase note for the slot. */
|
||||
#announce{position:fixed;left:50%;top:14px;transform:translateX(-50%) translateY(-14px);z-index:41;
|
||||
background:#16301f;border:1px solid #4c9a6a;color:#c8f0d8;border-radius:14px;padding:7px 18px;
|
||||
font-size:14px;font-weight:700;opacity:0;pointer-events:none;transition:opacity .18s,transform .18s;
|
||||
box-shadow:0 6px 22px rgba(0,0,0,.5);max-width:min(680px,90vw);text-align:center}
|
||||
#announce.shown{opacity:1;transform:translateX(-50%) translateY(0)}
|
||||
@media(prefers-reduced-motion:reduce){#announce{transition:none}}
|
||||
/* The die's answer, said where the player is looking. Green like a revenue gain — it is a good
|
||||
thing that just happened — and it disappears with the next render, as a moment should. */
|
||||
.scheduled{display:block;font-size:11.5px;margin:0 0 7px;padding:3px 9px;border-radius:5px;
|
||||
@@ -185,6 +193,7 @@ ul.blocked li{padding:2px 0}
|
||||
<!-- Announces a phase CHANGE. The turn chart says where you are; this says that it moved, which is
|
||||
what you miss when the automatic phases run between two clicks. -->
|
||||
<div id="phasenote"></div>
|
||||
<div id="announce"></div>
|
||||
|
||||
<main>
|
||||
<div>
|
||||
|
||||
@@ -86,6 +86,8 @@ export type Session = {
|
||||
takeCues(): string[];
|
||||
/** The timetable slot the last 1D12 filled, once. Draining. */
|
||||
takeScheduled(): number | null;
|
||||
/** A one-line announcement to flash, once. Draining. */
|
||||
takeAnnouncement(): string | null;
|
||||
/** The card most recently drawn into this seat's hand. Persists until another draw replaces it. */
|
||||
justDrawn(): string | null;
|
||||
};
|
||||
@@ -146,6 +148,11 @@ export function createLocalSession(seed: number, config?: GameConfig): LocalSess
|
||||
game.scheduled = null;
|
||||
return slot;
|
||||
},
|
||||
takeAnnouncement: () => {
|
||||
const text = game.announced;
|
||||
game.announced = null;
|
||||
return text;
|
||||
},
|
||||
justDrawn: () => game.justDrawn,
|
||||
|
||||
seed: () => game.seed,
|
||||
@@ -159,6 +166,7 @@ export function createLocalSession(seed: number, config?: GameConfig): LocalSess
|
||||
back.cues.length = 0;
|
||||
back.scheduled = null;
|
||||
back.justDrawn = null;
|
||||
back.announced = null;
|
||||
changed();
|
||||
return true;
|
||||
},
|
||||
@@ -166,6 +174,7 @@ export function createLocalSession(seed: number, config?: GameConfig): LocalSess
|
||||
game = fromSave(save);
|
||||
// Restoring replays the whole history and re-records every draw; none of it is news.
|
||||
game.justDrawn = null;
|
||||
game.announced = null;
|
||||
changed();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -124,6 +124,17 @@ export function playCue(cue: string): void {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cue === 'completed') {
|
||||
// A train clearing the Division and whistling off: two long horn notes falling away, with the
|
||||
// second quieter and lower so it reads as distance rather than repetition. Longer than any other
|
||||
// cue on purpose — it is the only one that pays every player, and it should land as an event.
|
||||
tone(t, 392, 0.55, 0.13, 'sawtooth', 370);
|
||||
tone(t, 262, 0.55, 0.09, 'triangle', 247);
|
||||
tone(t + 0.42, 330, 0.75, 0.08, 'sawtooth', 300);
|
||||
tone(t + 0.42, 220, 0.75, 0.055, 'triangle', 200);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cue === 'day') {
|
||||
// The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep.
|
||||
for (const at of [t, t + 0.22]) {
|
||||
|
||||
Reference in New Issue
Block a user