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:
+60
-43
@@ -9,7 +9,7 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
|
||||
import { HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||
import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { developerBot } from '../src/sim/bot.ts';
|
||||
@@ -690,9 +690,16 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
* them — it may be the rules are wrong, or it may be my perception."
|
||||
*
|
||||
* It was perception, but the log was actively feeding it. Every departure read alike, and the
|
||||
* arrival line for an Expedited train said there was "no turn in which to work it" — which is
|
||||
* false and cost the player the Cargo turn they did have. These pin the two claims the log now
|
||||
* makes, so a phase-order change cannot leave the narration lying about it.
|
||||
* arrival line for an Expedited train said there was "no turn in which to work it" — which was
|
||||
* false under the OLD (v0.4.8 and earlier) same-stage-departure reading of Q3, and cost the player
|
||||
* the Cargo turn they did have.
|
||||
*
|
||||
* v0.4.9 CORRECTED Q3 ITSELF, not just the narration: Expedite does not change when a train
|
||||
* leaves — it is released by the ordinary §8.1 rules exactly like any other train, and may be
|
||||
* switched normally while it stands. What it means is that the train must not be PARKED anywhere
|
||||
* but the station; leaving it on Secondary Track when a Mainline Phase begins is a Station Master
|
||||
* fault (`expediteFault`, `EXPEDITE_FAULT_PENALTY`). These pin that behaviour, so a phase-order
|
||||
* change cannot silently bring back the old forced departure.
|
||||
*/
|
||||
/**
|
||||
* Drop one named train at the Western Division Point and follow it, counting the DISTINCT phases
|
||||
@@ -704,11 +711,10 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
const s = game(7, { length: 'standard' });
|
||||
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
|
||||
/**
|
||||
* A DIVISION WITH NOTHING ELSE ON IT. Expedite is not absolute: §8.1 can still hold the train,
|
||||
* and `shiftChange` says so — "an expedited train that would need a ruling simply stays, and
|
||||
* runs normally next Stage". With the bot's own traffic running, Train 6 was held four Stages
|
||||
* and collected four Local Operations turns, which is correct behaviour and the opposite of what
|
||||
* this test is trying to pin. Clearing the timetable isolates the rule from the traffic.
|
||||
* A DIVISION WITH NOTHING ELSE ON IT. §8.1 can still hold any train, Expedited or not, on a
|
||||
* same-direction meet — with the bot's own traffic running, Train 6 was held four Stages and
|
||||
* collected four Local Operations turns. Correct behaviour, but it is traffic noise for what
|
||||
* this test is trying to pin, so clearing the timetable isolates the rule from it.
|
||||
*/
|
||||
s.timetable = s.timetable.map(() => null);
|
||||
const id = 'watched';
|
||||
@@ -779,40 +785,55 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
assert.equal(r.leftIn, 'mainline', 'an ordinary train should leave in a Mainline Phase');
|
||||
});
|
||||
|
||||
it('gives an Expedited train the Cargo phase but never Local Operations', () => {
|
||||
// Train 6 The Sparrow — Expedite. The claim the arrival line makes is precisely this pair:
|
||||
// Porters and Laborers can reach it, a switching turn never comes, and it goes at the end of
|
||||
// the Stage rather than in a Mainline Phase.
|
||||
it('gives an Expedited train a Local Operations turn too, exactly like an ordinary one', () => {
|
||||
// Train 6 The Sparrow — Expedite. v0.4.9 corrected Q3: Expedite no longer forces a same-stage
|
||||
// departure, so an Expedited train is released by the ordinary §8.1 rule and stands for a
|
||||
// Local Operations turn the same as Train 12 above.
|
||||
const r = phasesWith(6);
|
||||
assert.equal(r.localOps, 0, 'an Expedited train got a Local Operations turn after all');
|
||||
assert.ok(r.localOps >= 1, `an Expedited train got ${r.localOps} Local Operations turns`);
|
||||
assert.ok(r.loadUnload >= 1, 'an Expedited train never stood through a Cargo phase');
|
||||
assert.equal(r.leftIn, 'shiftChange', 'an Expedited train should leave in Supervisor Shift');
|
||||
assert.equal(r.leftIn, 'mainline', 'an Expedited train should leave in a Mainline Phase, like any other');
|
||||
});
|
||||
|
||||
it('does NOT expedite when §8.1 wants a ruling — it stays and runs normally', () => {
|
||||
/**
|
||||
* EXPEDITE IS CONDITIONAL, which is most of why it feels arbitrary at the table.
|
||||
*
|
||||
* `shiftChange` says so — "an expedited train that would need a ruling simply stays, and runs
|
||||
* normally next Stage" — and it happens often: driven by the developer bot this was reached by
|
||||
* ordinary traffic, and Train 6 collected FOUR Local Operations turns instead of none. Built by
|
||||
* hand here rather than fished out of a bot game, because whether the bot happens to produce a
|
||||
* meet depends on the deck, and the deck moves.
|
||||
*
|
||||
* So the log and the card must not promise that an Expedited train can never be switched.
|
||||
*/
|
||||
it('faults an Expedited train left off the station when a Mainline Phase begins', () => {
|
||||
// What Expedite actually restricts now: not WHEN the train leaves, but WHERE it may be left
|
||||
// standing in the meantime. Parked on Secondary Track — cleared there to make way for other
|
||||
// switching, say — it must not still be there when the next Mainline Phase begins.
|
||||
const s = game(7, { length: 'standard' });
|
||||
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
|
||||
const area = areaOf(s, 0);
|
||||
const id = 'expedited';
|
||||
const secondary = { row: area.officeCoord.row, col: area.officeCoord.col + 1 };
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: secondary }, movesUsed: 0,
|
||||
});
|
||||
|
||||
// Train 6 (Expedite) standing at the Office, having just arrived: this is the state
|
||||
// `arriveAtOffice` leaves behind when it sets `departsThisStage`.
|
||||
s.clock.phase = 'mainline';
|
||||
const before = s.players[0]!.revenue;
|
||||
const r = advance(s);
|
||||
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'expediteFault' && e.trainNumber === 6),
|
||||
'no fault was raised for an Expedited train left off the station',
|
||||
);
|
||||
assert.equal(
|
||||
s.players[0]!.revenue,
|
||||
before - EXPEDITE_FAULT_PENALTY,
|
||||
'the fault did not cost Revenue',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not fault an Expedited train legitimately held at the station itself', () => {
|
||||
// A train sitting on the Office square is not "left" anywhere — including one §8.1 is holding
|
||||
// for a same-direction meet, which is an ordinary hold, not a Station Master failure.
|
||||
const s = game(7, { length: 'standard' });
|
||||
const area = areaOf(s, 0);
|
||||
const id = 'expedited';
|
||||
s.trays.set(id, {
|
||||
id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
|
||||
direction: 'east', facing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||
departsThisStage: true,
|
||||
});
|
||||
area.adOccupancy.push(id);
|
||||
|
||||
@@ -829,26 +850,22 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
}
|
||||
|
||||
s.clock.phase = 'shiftChange';
|
||||
s.clock.phase = 'mainline';
|
||||
const before = s.players[0]!.revenue;
|
||||
const r = advance(s);
|
||||
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === id),
|
||||
'the Expedited departure was not put to the Superintendent',
|
||||
);
|
||||
assert.equal(
|
||||
s.trays.get(id)?.position.at,
|
||||
'grid',
|
||||
'the Expedited train left despite needing a ruling nobody could give',
|
||||
'the departure was not put to the Superintendent',
|
||||
);
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'trainHeld' && /EXPEDITES/.test(e.reason)),
|
||||
'the log does not say why the Expedited train stayed',
|
||||
!r.events.some((e) => e.type === 'expediteFault'),
|
||||
'a train properly parked at the station was faulted anyway',
|
||||
);
|
||||
assert.equal(
|
||||
s.trays.get(id)?.departsThisStage,
|
||||
false,
|
||||
'it should run as an ordinary train from now on, not retry the expedited departure',
|
||||
s.players[0]!.revenue,
|
||||
before,
|
||||
'Revenue changed for a train legitimately held at the station',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+56
-19
@@ -468,9 +468,10 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
|
||||
* Nothing states the rule, which makes it exactly the kind of property a later change to the loop
|
||||
* would break in silence. These pin it.
|
||||
*
|
||||
* It is also what gives the printed **Expedite** rule something to do — an Expedited train departs
|
||||
* at the END of the Stage it arrived, precisely because the general rule forbids it departing at
|
||||
* all. It stands through Load/Unload first, which is what makes its coaches workable.
|
||||
* v0.4.9 CORRECTED Q3 so **Expedite is not an exception to this any more**: an Expedited train
|
||||
* stops for the Stage exactly like any other and may be switched in the Local Operations turn that
|
||||
* follows. What Expedite restricts is where the train may be left standing afterwards, not when it
|
||||
* departs — see `advance.test.ts`'s `expediteFault` coverage for that half.
|
||||
*/
|
||||
const arriving = (s: GameState, trainNumber: number, isExtra = false): string => {
|
||||
const id = 'arriving';
|
||||
@@ -503,12 +504,14 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
|
||||
);
|
||||
});
|
||||
|
||||
it('unless its card prints Expedite — but it still stands through Load/Unload first', () => {
|
||||
it('an Expedite train follows the same rule now — no exception any more', () => {
|
||||
/**
|
||||
* Q3, as revised in v0.4.2. An Expedite train leaves the Stage it arrives, but at the END of that
|
||||
* Stage rather than inside the Mainline Phase. It used to be gone before Load/Unload ran, which
|
||||
* made the coaches on every coach-carrying express (1/2, 5/6, 19) permanently unworkable —
|
||||
* measured, Train 2 arrived 32 times in 60 games and stood for Load/Unload in none of them.
|
||||
* Q3, as CORRECTED in v0.4.9. Before this, an Expedite train left the Stage it arrived — at the
|
||||
* end of it rather than inside the Mainline Phase (v0.4.2's fix for coaches otherwise never
|
||||
* reaching Load/Unload) — which made it the one exception to "stops for the Stage". That forced
|
||||
* departure is gone: Expedite no longer changes WHEN a train leaves, only where it may be left
|
||||
* standing afterwards (`expediteFault`, pinned in `advance.test.ts`). So it stops for the Stage
|
||||
* exactly like Train 9 above.
|
||||
*/
|
||||
const s = game();
|
||||
assert.equal(trainProfile(3, false)?.rules.expedite, true, 'train 3 should be an Expedite train');
|
||||
@@ -518,19 +521,9 @@ describe('§8.1 — a train that just arrived from the mainline stops for the St
|
||||
|
||||
assert.equal(
|
||||
s.trays.get(id)!.position.at, 'grid',
|
||||
'an Expedite train left inside the Mainline Phase, before Load/Unload could reach it',
|
||||
'an Expedite train departed again in the same Stage it arrived from a mainline card',
|
||||
);
|
||||
assert.equal(s.trays.get(id)!.departsThisStage, true, 'it is not flagged to leave at the end of the Stage');
|
||||
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'it is not standing on an A/D track');
|
||||
|
||||
// Run the end of the Stage directly. Load/Unload waits on the player, and the thing under test
|
||||
// is the departure pass that follows it — not that phase's own input handling.
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.notEqual(
|
||||
s.trays.get(id)?.position.at, 'grid',
|
||||
'an Expedite train should be gone by the end of the Stage it arrived',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -666,6 +659,50 @@ describe('Telegraph, Telephone and Radio dispatch meets', () => {
|
||||
assert.deepEqual(s.trays.get('mine')!.position, { at: 'divisionPoint', side: 'west' });
|
||||
});
|
||||
|
||||
it('holds against an uncleared facing train even if a same-direction one is checked first', () => {
|
||||
// Reproduces a playtest report: the Superintendent was asked to rule on a same-direction train
|
||||
// instead of being automatically held against an opposite-direction one also occupying the
|
||||
// card. Root cause — `evaluateClearance` returned on whichever occupant it examined first, in
|
||||
// `node.transits` insertion order, rather than checking every occupant. Only visible once a
|
||||
// card can hold two trains at once, which the Telegraph/Telephone/Radio exception is exactly
|
||||
// what creates: a facing train dispatched past another sits on the same card as it.
|
||||
const s = game();
|
||||
const ml = s.division.nodes[1];
|
||||
assert.equal(ml?.kind, 'mainline');
|
||||
if (ml?.kind === 'mainline') {
|
||||
ml.card = 'plains';
|
||||
// SAME direction, entered the transit list FIRST — what the old code asked about.
|
||||
s.trays.set('sameDir', {
|
||||
id: 'sameDir', trainNumber: 3, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
ml.transits.push({ tray: 'sameDir', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
// OPPOSITE direction, entered SECOND, no dispatch device on the board — an absolute bar.
|
||||
s.trays.set('facing', {
|
||||
id: 'facing', trainNumber: 5, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'west', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
});
|
||||
ml.transits.push({ tray: 'facing', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||||
}
|
||||
s.trays.set('mine', {
|
||||
id: 'mine', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||
});
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
|
||||
const r = advance(s);
|
||||
|
||||
assert.equal(
|
||||
s.trays.get('mine')!.position.at, 'divisionPoint',
|
||||
'the train entered despite an uncleared opposite-direction occupant on the card',
|
||||
);
|
||||
assert.ok(
|
||||
!r.events.some((e) => e.type === 'clearanceRequested'),
|
||||
'the Superintendent was asked to rule on the same-direction occupant instead of an automatic hold',
|
||||
);
|
||||
});
|
||||
|
||||
it('lets a junior train win the meet with a Telegraph', () => {
|
||||
// Train 9 against Train 3: +4 makes the other count as 7 — still senior. Radio's +12 wins.
|
||||
const s = game();
|
||||
|
||||
@@ -46,6 +46,7 @@ const KNOWN_UNREDUCED = [
|
||||
'carPassed',
|
||||
'clearanceRequested',
|
||||
'dispatchBonusUsed',
|
||||
'expediteFault',
|
||||
'phaseBegan',
|
||||
'stageBegan',
|
||||
'trainArrived',
|
||||
|
||||
@@ -446,6 +446,13 @@ describe('the replay behaves like the game it is replaying', () => {
|
||||
assert.ok(kinds.has('couple'), 'coupling a cut never made a sound');
|
||||
assert.ok(kinds.has('drop'), 'setting cars out never made a sound');
|
||||
assert.ok(kinds.has('schedule'), 'the 1D12 that sets a train\'s departure Stage landed silently');
|
||||
assert.ok(kinds.has('arrive'), 'a train pulling into an Office never made a sound');
|
||||
assert.ok(kinds.has('depart'), 'a train highballing out of an Office never made a sound');
|
||||
// Collisions are rare — measured 2 in 40 games — so this is the one cue this pool is not
|
||||
// guaranteed to contain on every stride; it happens to (seeds 96028 and 159380) at the current
|
||||
// stride and seed count. If this starts failing after either changes, widen the pool rather than
|
||||
// deleting the assertion — §10 is the one event a player most needs to hear.
|
||||
assert.ok(kinds.has('crash'), 'a collision never made a sound');
|
||||
|
||||
// One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same
|
||||
// accounting the live game is held to. The switching cues are counted separately: they follow
|
||||
|
||||
+2
-2
@@ -345,8 +345,8 @@ describe('end-of-game statistics', () => {
|
||||
* BOT no longer gets a crew next to an industry: it builds shallow districts and places 2.2
|
||||
* facilities a game, down from 3.8 when industries could sit on the main.
|
||||
*
|
||||
* That is the bot's district-building weakness, which is the top item in TODO.md, not a rule
|
||||
* that has become unreachable. Exempted by name so the other forty-odd event checks stay live,
|
||||
* That is the bot's district-building weakness, tracked in TODO.md under Bot Performance, not a
|
||||
* rule that has become unreachable. Exempted by name so the other forty-odd event checks stay live,
|
||||
* and so removing this line is what proves the bot has been fixed.
|
||||
*/
|
||||
const KNOWN_UNREACHABLE_BY_THE_BOT = ['event flyingSwitch'];
|
||||
|
||||
@@ -76,13 +76,23 @@ const coach = (loaded = false): RollingStock => ({ type: 'coach', loaded });
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('§7 — no switching', () => {
|
||||
it('refuses every switching act to a train whose card forbids it', () => {
|
||||
it('still allows a plain move — "no switching" means no adding or dropping cars', () => {
|
||||
// Six cards print it: both expresses, the Light Engine, Campaign, Circus and Military trains.
|
||||
// Moving, setting out and sorting are all switching, so all three are refused.
|
||||
// A train held at the Office may still need to clear onto Secondary Track ahead of other
|
||||
// traffic, so a move that couples nothing is legal.
|
||||
const s = game();
|
||||
switching(s, 1, false, [boxcar()]);
|
||||
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null);
|
||||
});
|
||||
|
||||
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), 'NO_SWITCHING');
|
||||
it('refuses a move that would couple a car, and refuses dropping and sorting outright', () => {
|
||||
const s = game();
|
||||
switching(s, 1, false, [boxcar()], [boxcar()]);
|
||||
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }),
|
||||
'PICKUP_NOT_ALLOWED',
|
||||
);
|
||||
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), 'NO_SWITCHING');
|
||||
assert.equal(check(s, 0, { type: 'switch.sortConsist', trayId: 't', order: [0] }), 'NO_SWITCHING');
|
||||
});
|
||||
|
||||
+8
-7
@@ -841,7 +841,7 @@ describe('board highlighting', () => {
|
||||
}
|
||||
assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`);
|
||||
assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`);
|
||||
assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree');
|
||||
assert.ok(sp.label.includes(`(${sp.coord.col}, ${sp.coord.row})`), 'label and coord disagree');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -3113,18 +3113,19 @@ describe('two crews switching are told apart', () => {
|
||||
assert.notDeepEqual(f.moves[0]!.from, f.moves[1]!.from);
|
||||
});
|
||||
|
||||
it('leaves out a train the rules will not let switch at all', () => {
|
||||
// `movesFor` is pure track geometry; six cards print "no switching" and `check` enforces it.
|
||||
// Offering the Circus Train in a "which train are you switching?" row offers a train whose
|
||||
// every move is refused.
|
||||
it('still offers a no-switching train as a crew to switch — it may be moved, just not worked', () => {
|
||||
// v0.4.9 — six cards print "no switching", which means may not couple, set out or sort, not
|
||||
// "may never be touched": a train held at the Office may still need to clear onto Secondary
|
||||
// Track ahead of other traffic. `check` refuses the coupling itself, not the move, so this row
|
||||
// is not filtered any differently for a no-switching train than for any other.
|
||||
const game = twoCrews();
|
||||
const s = game.state;
|
||||
const circus = [...s.trays.values()][0]!;
|
||||
circus.trainNumber = 18;
|
||||
circus.trainIsExtra = true; // X18 Circus Train — noSwitching
|
||||
const f = view(game);
|
||||
assert.equal(f.moves.length, 1, 'a no-switching train was offered as a crew to switch');
|
||||
assert.equal(f.moves[0]!.label, 'Train 12');
|
||||
assert.equal(f.moves.length, 2, 'a no-switching train was left out of the crews offered to switch');
|
||||
assert.deepEqual(f.moves.map((m) => m.label).sort(), ['Train 12', 'Train X18']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user