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:
@@ -552,7 +552,8 @@ export function officeSvg(
|
||||
|
||||
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
|
||||
|
||||
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
|
||||
// X,Y — east/west then north/south, not the internal row/col storage order.
|
||||
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.col},${cell.row}</text>`;
|
||||
|
||||
/**
|
||||
* THE ROSTER, one chip per A/D track — not one per train.
|
||||
@@ -872,7 +873,7 @@ export function officeSvg(
|
||||
out += `<g data-ghost="${g.row},${g.col}" transform="translate(${px(g.col)},${py(g.row)})" class="bs-ghost">` +
|
||||
`<rect x="0" y="0" width="${W}" height="${H}" rx="4"/>` +
|
||||
`<text x="${W / 2}" y="${H / 2}" text-anchor="middle">place here</text>` +
|
||||
`<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${g.row},${g.col}</text>` +
|
||||
`<text class="bs-coord" x="${W / 2}" y="${H / 2 + 15}" text-anchor="middle">${g.col},${g.row}</text>` +
|
||||
`</g>`;
|
||||
}
|
||||
|
||||
|
||||
+24
-30
@@ -280,25 +280,25 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
||||
};
|
||||
case 'trainArrived':
|
||||
/**
|
||||
* AN EXPEDITED TRAIN IS NOT COMING BACK ROUND — AND IS NOT GONE YET EITHER.
|
||||
* AN EXPEDITED ARRIVAL IS AN ORDINARY ARRIVAL, WITH ONE STANDING OBLIGATION.
|
||||
*
|
||||
* This said "it will highball again next Mainline Phase" to every arrival, which is backwards
|
||||
* for an Expedited train. Corrected once to "it leaves again this same Mainline Phase", which
|
||||
* was ALSO wrong and in a way that cost the player work: the expedited departure was moved to
|
||||
* Supervisor Shift precisely so the Porters and Laborers get their Stage with the train, so
|
||||
* Cargo IS available and only Local Operations is not. Telling a player there is "no turn in
|
||||
* which to work it" talked them out of the one turn they had.
|
||||
*
|
||||
* So both branches now name the phases: what you can still do with this train, and when it
|
||||
* goes. That is the whole of the reported confusion — "some trains seem to be moving before I
|
||||
* can switch or do other operations on them".
|
||||
* This used to say the train stands through Cargo and is forced out at the end of the Stage —
|
||||
* true once, and wrong: Q3 means the train must not be PARKED anywhere but the station, not
|
||||
* that it is rushed out early. It is switched, worked and released exactly like any other
|
||||
* arrival; the only difference is what happens if it is left on Secondary Track when the next
|
||||
* Mainline Phase begins (`expediteFault`).
|
||||
*/
|
||||
return {
|
||||
tone: 'plain',
|
||||
text: e.expedited
|
||||
? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so it stands through the Cargo phase and departs at the END OF THIS STAGE. Porters and Laborers can work it now; it is normally gone before your next Local Operations turn, so it cannot be switched — unless §8.1 holds it for a ruling, in which case it stays and runs like any other train.`
|
||||
? `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — its card prints EXPEDITE, so keep it on the Office square: parked anywhere else in the district when the next Mainline Phase begins costs a Revenue point. It works and switches normally in the meantime.`
|
||||
: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it stands here for the rest of this Stage. You can work it in Cargo now, switch it in the NEXT Stage's Local Operations, and it departs in that Stage's Mainline Phase.`,
|
||||
};
|
||||
case 'expediteFault':
|
||||
return {
|
||||
tone: 'bad',
|
||||
text: `Train ${e.trainNumber} was left at ${e.where}, off the station, when the Mainline Phase began — an expedited train must be kept ready to highball. Station Master fault.`,
|
||||
};
|
||||
case 'trainDiverted':
|
||||
return {
|
||||
tone: 'good',
|
||||
@@ -582,7 +582,7 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
||||
// the board says it on the card instead.
|
||||
if (b.kind === 'noStopping') continue;
|
||||
out.push({
|
||||
where: `${tray.trainNumber === null ? 'crew' : `Train ${tray.trainNumber}`} → (${b.coord.row},${b.coord.col})`,
|
||||
where: `${tray.trainNumber === null ? 'crew' : `Train ${tray.trainNumber}`} → (${b.coord.col},${b.coord.row})`,
|
||||
why: b.why,
|
||||
// Not "stuck": these are the shape of the district and of the other trains in it, which is
|
||||
// the puzzle rather than a fault. Amber, not red.
|
||||
@@ -602,12 +602,10 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
||||
* said so.
|
||||
*
|
||||
* 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
|
||||
* them", which turned out to be two 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.
|
||||
* Porters used" — to a player who had used none.
|
||||
*
|
||||
* §9.2 also requires an empty coach in the DIVISION YARD to swap into the train, which no panel
|
||||
* mentioned at all.
|
||||
@@ -620,8 +618,6 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
||||
.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 });
|
||||
};
|
||||
@@ -658,17 +654,6 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
|
||||
say('no EMPTY coach in the Division Yard to swap into the train (§9.2 requires one)');
|
||||
}
|
||||
}
|
||||
|
||||
// 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,6 +733,15 @@ export function cuesFor(events: readonly GameEvent[]): string[] {
|
||||
// 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');
|
||||
// A train pulling into an Office. `trainDiverted` (the Yard Office siding) is deliberately not
|
||||
// included — that one goes straight to a work track without the A/D stop `arrive` depicts.
|
||||
if (e.type === 'trainArrived') out.push('arrive');
|
||||
// Only an OFFICE departure, not every highball: a fresh make-up leaving a Division Point already
|
||||
// sounds `train` ("All aboard"), and a train clearing the whole Division already sounds
|
||||
// `completed` — this is the one case neither of those covers.
|
||||
if (e.type === 'trainHighballed' && e.from === 'the Office') out.push('depart');
|
||||
// §10 — the one event nobody wants to hear and everybody needs to.
|
||||
if (e.type === 'trainsDestroyed') out.push('crash');
|
||||
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,
|
||||
|
||||
+14
-19
@@ -678,7 +678,8 @@ function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
|
||||
|
||||
/** One readable line for a single intent. */
|
||||
export function describeIntent(s: GameState, i: Intent): string {
|
||||
const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`;
|
||||
// X,Y — east/west then north/south, not the internal row/col storage order.
|
||||
const at = (c: { row: number; col: number }): string => `(${c.col},${c.row})`;
|
||||
// An intent belongs to whoever is acting, so it is described against THEIR district.
|
||||
// The acting PLAYER, not a seat — `describeIntent` describes an intent against the district of
|
||||
// whoever is making it. Named `seat` once, and then used as an `officeAreas` key, which is the
|
||||
@@ -1229,7 +1230,7 @@ export function snapshot(
|
||||
? `${t.position.side} Division Point`
|
||||
: t.position.at === 'mainline'
|
||||
? `Mainline card ${t.position.index}`
|
||||
: `Office Area (${t.position.coord.row},${t.position.coord.col})`,
|
||||
: `Office Area (${t.position.coord.col},${t.position.coord.row})`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -1424,7 +1425,9 @@ export function trainRules(t: {
|
||||
* DOES rather than that it exists, because the restriction is the whole character of the card: a
|
||||
* Military train that cannot be worked by Porters plays nothing like a Local.
|
||||
*/
|
||||
if (p.rules.noSwitching) parts.push('NO SWITCHING — it runs the Division and does not shunt');
|
||||
if (p.rules.noSwitching) {
|
||||
parts.push('NO SWITCHING — may not add or drop cars, but may still be moved clear of the mainline');
|
||||
}
|
||||
if (p.rules.terminalsOnly) parts.push('TERMINALS ONLY — Porters may work it at a Terminal and nowhere else');
|
||||
if (p.rules.coachStaysOnStationTrack) {
|
||||
// Said "may only be set out at the Office", which reads as a place you can do it. You cannot:
|
||||
@@ -1446,15 +1449,12 @@ export function trainRules(t: {
|
||||
}
|
||||
|
||||
if (p.rules.expedite) {
|
||||
// The single most-asked question in play — "why did that train leave before I could switch it?"
|
||||
// — so it names the phases rather than the Stage. It stands through Cargo and goes at the end of
|
||||
// the Stage, which is after the Porters and Laborers but before the next Local Operations.
|
||||
// It is released and switched exactly like any other train — the restriction is on where it may
|
||||
// be LEFT, not on when it leaves.
|
||||
parts.push(
|
||||
'EXPEDITED — it does not lay over. It stands through the Cargo phase and departs at the END OF ' +
|
||||
'THE STAGE IT ARRIVES IN, so Porters and Laborers can work it but it is normally gone before ' +
|
||||
'your next Local Operations turn and cannot be switched. UNLESS §8.1 wants a clearance ruling ' +
|
||||
'at that moment: there is no Superintendent in Supervisor Shift to give one, so the train ' +
|
||||
'stays and runs like any other from then on.',
|
||||
'EXPEDITED — must be kept ready to highball. It works and switches normally, but if it is not ' +
|
||||
'back on the Office square when the next Mainline Phase begins, that is a Station Master ' +
|
||||
'fault and costs 1 Revenue.',
|
||||
);
|
||||
}
|
||||
if (p.rules.stopEarnsPoint) parts.push('EARNS A POINT for one Stage spent standing still, once');
|
||||
@@ -1736,14 +1736,9 @@ function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] {
|
||||
const out: Frame['moves'] = [];
|
||||
for (const [id, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
/**
|
||||
* A crew that may not switch is not a crew you can be switching.
|
||||
*
|
||||
* `movesFor` is pure track geometry — it answers where a train COULD roll — and six cards print
|
||||
* "no switching", which `check` enforces and it does not. Offering the Circus Train in a "which
|
||||
* train are you switching?" row is offering a train whose every move the rules refuse.
|
||||
*/
|
||||
if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.noSwitching) continue;
|
||||
// A no-switching train may still be moved clear of the mainline (§7) — only coupling, setting
|
||||
// out and sorting are refused, and `check` rejects those the same way it rejects a pick-up X13
|
||||
// (dropOnly) is not allowed to make, so this row is not filtered any differently for either.
|
||||
const { to, blocked } = movesFor(s, player, id);
|
||||
out.push({ trayId: id, label: trainName(s, id), from: tray.position.coord, to, blocked });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user